Хочу поделиться с вами опытом написания компонента выбора даты для текстового поля.



Результат работы можно посмотреть здесь: Air Datepicker.

Введение


Работая над последним проектом, возникла необходимость добавить в приложение календарь с возможностью выбрать конкретный месяц. Все популярные плагины такую возможность предоставляют, мой выбор остановился на Zebra Datepicker — маленький, функциональный, все здорово. Но некоторых вещей все же не хватало:

  1. передача объектов Date() в параметры вместо строк
  2. менее громоздкая разметка
  3. гибкое позиционирование элемента
  4. анимация при появлении

Сколько не приходилось работать с датой, почти всегда в исходных данных она хранилась в unix формате, и для меня остается загадкой, почему во многих плагинах при задании, к примеру, минимально возможной даты, нужно передавать строку: нужно получить дату, затем переделать ее в строку и уже потом передать плагину, вместо того, чтобы просто отдать new Date(time).

Что касается громоздкой разметки, то к ней добавляется еще и табличная верстка, к ячейкам которой без лишних проблем не добавить position: relative;.

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

Разработка


Календарь я разделил на три части:

// Основная часть
Datepicker

// Тело календаря
Datepicker.Body

// Навигация
Datepicker.Navigation

При наступлении каких-либо событий в теле или навигации, они сообщают об этом основной части, и календарь обновляет свое состояние в соответствии с этими событиями.

В этой задаче мне помогли getter'ы и setter'ы. Например, при изменении месяца просто присваивается новая отображаемая дата с измененным номером месяца, и внутри геттера вызывается метод перерисовки тела и навигации календаря. Несмотря на то, что можно было бы обойтись и без них, мне данный подход представляется более красивым. К примеру, вот так выглядит метод перехода к следующему месяцу, году или декаде, в зависимости от текущего вида:

next: function() {
    var d = this.parsedDate;
    
    switch (this.view) {
        case 'days':
            this.date = new Date(d.year, d.month + 1, 1);
            break;
        case 'months':
            this.date = new Date(d.year + 1, d.month, 1);
            break;
        case 'years':
            this.date = new Date(d.year + 10, 0, 1);
            break;
    }
}

В свою очередь внутри геттера происходит вызов отрисовки элементов календаря (упрощенно):

set date (val) {
    this.currentDate = val;

    this.currentView._render();
    this.nav._render();
}

Точно так же происходит переход на другой вид, очень просто:

this.view = 'months';


Формирование разметки


Основа для календаря выглядит следующим образом:

<div class="datepicker">
    <i class="datepicker--pointer"></i>
    <nav class="datepicker--nav"></nav>
    <div class="datepicker--content"></div>
</div>

Без таблиц и намека на них. Ячейка является простым <div> … </div>, что дает возможность добавлять псевдо элементы к ним и позиционировать контент внутри них как захочется.

Я не вижу особого смысла в разделении на ряды ячеек, так как это дополнительный ненужный элемент. Все даты идут друг за другом, у них задана относительная ширина, которая позволяет осуществлять переход на другую строку в нужный момент.

Вычисление общего количества дней в месяце


Чтобы сформировать корректный HTML, нужно знать сколько дней в месяце. Для этого используется небольшой трюк с передачей следующего месяца и нулевой даты (в Date() дата месяца начинается с единицы).

Datepicker.getDaysCount = function (date) {
    // Например, нам нужно узнать сколько дней в декабре, передаем следующий месяц, получается январь.
    // Но из-за того, что вместо 1, мы передали 0, он указывает на последний день предыдущего месяца,
    // что в итоге и дает нам 31 число, или 31 день.
    return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
};

Формирование названий дней




Когда инициализируешь календарь, можно задавать день, с которого начинается неделя. Мне показалось интересным показать как можно сформировать разметку с названиями дней с помощью рекурсии:

/**
* @param firstDay - День, с которого начинается неделя
* @param [curDay] - Текущий день, для которого формируется разметка
* @param [html] - Весь html доступный на данный момент
* @param [i] - Текущий номер дня недели
*/
_getDayNamesHtml: function (firstDay, curDay, html, i) {
    curDay = curDay != undefined ? curDay : firstDay;
    html = html ? html : '';
    i = i != undefined ? i : 0;

    // Если прошли все 7 дней, возвращаем готовый html
    if (i > 7) return html;
    // Если дошли до последнего дня недели, а общий счетчик еще не больше 7, начинаем с первого дня недели
    if (curDay == 7) return this._getDayNamesHtml(firstDay, 0, html, ++i);

    html += '<div class="datepicker--day-name' + (this.isWeekend(curDay) ? " -weekend-" : "") + '">' + this.localization.daysMin[curDay] + '</div>';

    return this._getDayNamesHtml(firstDay, ++curDay, html, ++i);
},

Использование flexbox


Для позиционирования внутри календаря я использую flexbox. Он с легкостью позволяет отцентрировать контент внутри ячеек, будет по центру во всех браузерах (которые поддерживают эту технологию) и на разных ОС, в отличие от техники задания высоты и такого же междустрочного интервала.

Плюс он позволяет располагать элементы на равноудаленном расстоянии друг от друга всего одной строчкой:

.datepicker--nav {
    justify-content: space-between;
}

Не нужно беспокоиться о разных значениях ширины, все будет рассчитываться автоматически.

Можно также упомянуть про кнопки «Сегодня» и «Очистить»:



Если их две, они занимают по 50% ото всей ширины, если одна, то она занимает всю ширину. Этого также можно достичь одной строкой:

.datepicker--button {
    flex: 1;
}

Это означает, что элемент в случае необходимости может как увеличиваться в размерах, так и уменьшаться, но при этом размеры всех соседей будут одинаковые. Когда кнопка одна, она расширяется на всю ширину, когда две, они пропорционально уменьшаются и занимают по 50%, и т.д. Можно добавлять сколько угодно элементов, у всех них будут одинаковые размеры, и в сумме они будут занимать всю ширину родителя.

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

Позиционирование


Позиция элемента задается двумя значениям:

  1. сторона, с которой будет появляться календарь
  2. положение на этой стороне

Если нужно расположить календарь сверху справа, то значение будет выглядеть как:

{
    position: 'top left'
}

Для того, чтобы добавить анимацию «подъезжания» к текстовому полю, я добавил вспомогательные классы, которые говорят с какой стороны нужно начинать анимацию. В данном случае этот класс выглядел бы как .-from-top-. За анимацию отвечают css transition и css transform. Это позволяет достичь плавности, а также добавлять кастомные переходы.

Что касается Date()


Как я упоминал вначале, мне не совсем понятны ситуации, когда вместо объекта даты нужно передавать строку. Возможно это удобно при автоматической инициализации, когда параметры нужно передавать через data атрибуты, но для меня все же удобнее просто передать new Date(). Тем более, что запись вида new Date(2015, 11, 17) не особо сложнее '2015-12-17'. Поэтому у меня во всех параметрах, где задается дата, необходимо передавать new Date().

Несколько слов об использовании


Мне нравится практика автоматической инициализации плагинов, поэтому для инициализации календаря к текстовому полю достаточно добавить класс 'datepicker-here' и все заработает.

<input type="text" class="datepicker-here" data-position="top right" data-min-view='months'/>

Опции можно передать через data атрибуты.

Кастомизируемое содержимое ячейки


В Air Datepicker есть возможность полностью изменять содержимое ячеек. Это позволяет добавлять, например, названия событий или какой-то вспомогательный контент в ячейки. Для этого нужно использовать опцию onRenderCell():

$('#datepicker').datepicker({
    // Добавим свой контент во все ячейки с датой 31 декабря.
    onRenderCell: function (date, cellType) {
        if (cellType == 'day' && date.getDate() == 31 && date.getMonth() == 11) {
            return {
                classes: '-ny-',
                html: 'Новый год!'
            }
        }
    }
})

Заключение


В итоге я могу сказать, что получил неплохой опыт, улучшил свои навыки работы с датой и написания документации. Календарь получился небольшим: всего 20kb (минифицированный js файл), но достаточно функциональным, по крайней мере для меня он свои задачи выполняет. Буду рад, если он или эта статья кому-нибудь поможет.

Спасибо за внимание.

Комментарии (33)


  1. bromzh
    02.12.2015 15:25
    +7

    Выглядит неплохо.
    Но почему не использовался moment.js? Он из коробки умеет локализацию и в нём есть настройки локали для кучи языков.
    А вообще, календарей на js и так полно, а вот календарей с выбором диапазона очень мало. И из всех, что я видел, самый хороший в личном кабинете тинькова. Так что если есть желание развить этот календарик, то реквестирую выбор диапазона дат.


    1. georgich
      02.12.2015 15:43

      А можно попросить скрин из кабинета? Сам ищу с диапазоном и временем. Благодарю!


      1. bromzh
        02.12.2015 16:02

        Выбор диапазона нужен был в ангуляр-проекте, в итоге я взял этот календарь и написал директиву-обёртку. В принципе, он похож на тиньковский, но для диапазона открываются 2 календаря (а не один, как у тинькова).

        P.S. К сожалению, не могу скрин снять, т.к. я не их клиент. Я сам смотрел у друзей.


      1. Mixalych
        02.12.2015 16:38

        Тоже ищу как у тинькова, сами используем из двух календарей, не напрягает, но хочется красивого.

        Календарь тинькова
        image


        1. 4utep
          02.12.2015 17:29
          +3

          1. Mixalych
            04.12.2015 13:18

            Да, спасибо за наводку!


    1. t1m0n
      02.12.2015 15:55
      +2

      Я с Moment.js не особо знаком, плюс не хотел добавлять лишние зависимости. А на счет диапазона дат, то в планах это есть, нужно побольше изучить этот вопрос. Спасибо за отзыв.


      1. bromzh
        02.12.2015 16:11
        +3

        Имхо, момент стоит того, чтобы включить его в проект. Много готовых и проверенных локалей, много плюшек, облегчающих работу с датами.


      1. mannaro
        02.12.2015 17:17
        +1

        Я тоже голосую за подключение momentjs. очень уж удобная штука.
        P.S: календарь офигенен. Тащу к нам на сайт.


    1. faiwer
      02.12.2015 20:02
      +2

      Он даже в зипованном варианте весит 12.4 KiB. Зачем его тащить в календарь из-за нескольких мелочей?
      Я не против этой библиотеки, но зависимость много-много-кратно превышающую саму библиотеку тащить из-за нескольких необходимых функций? С локалями уже, кстати, 45 KiB (если верить оф.сайту).


  1. nazarpc
    02.12.2015 15:43

    Zebra Datepicker ужасен.

    • передача объектов Date() в параметры вместо строк
    • менее громоздкая разметка
    • гибкое позиционирование элемента
    • анимация при появлении


    Кроме анимации всё это уже давно есть в весьма популярном PickMeUp. Нет анимации потому что у PickMeUp нет предубеждений по поводу того, как он должен выглядеть. То есть нужно было всего лишь стилизовать PickMeUp и всё. Верстки там ещё меньше чем у вас генерируется.


    1. t1m0n
      02.12.2015 17:44

      Возможно так и есть, у меня не было цели сделать самый лучший в мире календарь или кого-то удивить. Просто было желание написать свой и я написал.


  1. dom1n1k
    02.12.2015 16:14
    +2

    На айпаде при любом действии он как-то неприятно мерцает.


    1. t1m0n
      02.12.2015 16:52
      +2

      Нужно будет посмотреть, спасибо за репорт.


  1. antirek
    02.12.2015 16:21
    +1

    Отличный виджет, легкий, красивый.

    Документация тоже аккуратная, вы ее могли бы также разместить на гитхабе github.io (инструкция pages.github.com), может быть даже в папке проекта, т.к. может быть вы захотите на свое домене сделать однажды онлайн-казино: ) и удалите доку.


    1. t1m0n
      02.12.2015 17:06

      Спасибо за отзыв!

      Вначале так и хотел сделать, но что-то не срослось в итоге. Возможно в будущем перенесу на pages.


  1. Leency
    02.12.2015 17:04
    +4

    Чувак, я специально залогиннился, чтобы сказать, что он офигенен.
    Сам лид в команде QA, сайты на Drupal. Если бы у нас был такой календарь, я бы получал микрооргазм каждый раз открывая его.


    1. t1m0n
      02.12.2015 17:11

      Спасибо, рад что вам понравилось.


  1. TNK
    02.12.2015 17:06

    При выборе нескольких дат виджет неадекватно реагирует на редактирование содержимого инпута: не отображает изменения и не дает выбрать введённую дату. Да и при одиночном выборе отредактированная дата не отображается.


    1. t1m0n
      02.12.2015 17:31

      Ответил вам ниже, немного промахнулся.


  1. t1m0n
    02.12.2015 17:21

    Да, сейчас нет работы с событиями клавиатуры, планирую добавить кое-что. Но тем не менее отслеживать изменения довольно сложно, потому что, к примеру, если формат даты будет «Сегодня dd число, yy год и еще что-нибудь», отследить валидность будет трудновато, если вообще возможно. Я думаю в таких случаях лучше использовать «readonly» атрибут у текстового поля.


    1. TNK
      02.12.2015 18:06
      +1

      По моему скромному мнению, это функционал из категории «must have». Рекомендую взглянуть, как это реализовали создатели других известных решений.


  1. Aingis
    02.12.2015 17:33
    +5

    Почему-то все велосипедостроители, думают, что их творением будут пользоваться только мышкой. Использовать клавиатуру с этим календарикам невозможно. Фокус не ставится, навигация стрелочками не работает. Даже набрал «20.12.2014» — реакции нет, выделен сегодняшний день. А как вашим календарём будут пользоваться незрячие? Aria-разметка отсутствует как класс. Даже мышкой, диапазон дат, как уже сказали, не выбрать. Выбор даты рождения, скажем, требует слишком много кликов. Проще ввести её руками. Вообще, идеальное поле ввода даты — текстовое поле, которое принимает любые значения вроде «21 апр '61».

    А какую задачу решает эта реализация? В лучшем случае этот календарик позволяет выбрать дату, поблизости от текущей. И это за 20 КБ скрипта? Как-то не впечатляет. API, конечно, впечатляет, но зачем, например, может понадобится onChangeDecade? Видно, что сценарии использования не проработаны.


    1. t1m0n
      02.12.2015 18:06

      На данный момент действительно не работают события клавиатуры. Но вы не правы что я не думаю об этом, просто требуется больше времени, и в будущих версиях планирую это исправить.

      В первую очередь я разрабатывал календарь для себя, и мои задачи он решает. Если вам требуется чего-то большего, то вы можете реализовать свой вариант, или сделать форк и улучшить текущий.


      1. frst
        03.12.2015 15:33

        выбор с клавиатуры не нужен, человеку без мышки проще вбить дату цифрами в текстовое поле, главное чтобы был очевиден для пользователя требуемый формат даты и чтобы виджет не мешался при клавиатурном вводе
        самый правильный и удобный для разработчика datepicker — это rome


    1. ionicman
      03.12.2015 13:11

      Практически все контролы каленадрей рассчитаны либо под пальцы, либо под мышь — т.к. выбрать дату из текущего месяца еще более-менее реально, а вот выбрать год/месяц/дату, год — это надо (для примера):

      1. нажать «вниз»/«вверх» — открыть календарь
      2. курсором добежать до выбора года
      3. нажать «ввод» — открыть список с годами
      4. добежать до нужного года
      5. нажать «ввод» — выбрать год
      6. добежать до выбора месяца
      7. нажать «ввод» — открыть список месяцев
      8. добежать до нужного месяца
      9. нажать «ввод» — выбрать месяц
      10. добежать до нужной даты
      11. нажать «ввод» — выбрать дату

      Кто в здравом уме будет это делать?

      Только если это реально необходимо, как и доп.разметка.

      Это имеет смысл только если это конкретно нужно на данном сайте.
      Какой прок от разметки для незрячих в календаре на сайте с фотками природы (ну или на сайте, где кроме календаря ничего не сделано для незрячих)?
      Какой смысл ориентироваться на клавиатуру при 99% пользователей с тачем или мышой?

      Цель оправдывает средства — писать все максимально универсально и для всех — Вы тогда проект вообще никогда не выпустите.

      А критиковать всегда просто ;)


      1. Aingis
        04.12.2015 14:30

        > Какой прок от разметки для незрячих в календаре на сайте с фотками природы (ну или на сайте, где кроме календаря ничего не сделано для незрячих)?

        Бывают слабовидящие, а не только незрячие. Фотку на весь экран они разглядят, а мелкие элементы интерфейса нет.

        > Практически все контролы каленадрей рассчитаны либо под пальцы, либо под мышь — т.к. выбрать дату из текущего месяца еще более-менее реально, а вот выбрать год/месяц/дату, год — это надо (для примера):

        Можно реагировать на ввод цифр, к примеру. В идеале, представление даты само должно меняться при вводе в поле ввода.

        > Какой смысл ориентироваться на клавиатуру при 99% пользователей с тачем или мышой?

        Я помню ещё ужасные грязные шариковые мышки, которые ужасно проскальзывают, мышка может очень плохо ездить. Тачи даже на маках не очень удобны для мелких действий (поиграйте на таче в Старкрафт, ага), а на других устройствах вообще очень неудобны. Опять же бывает голосой ввод, если говорить о доступности. Как интерфейс приспособлен к нему? А никак.

        > Цель оправдывает средства — писать все максимально универсально и для всех — Вы тогда проект вообще никогда не выпустите.

        Нет, надо написать 100500 календарик со стандартными граблями. Пользователи должны страдать! А всё почему? Потому что не сформулированы даже базовые требования и граничные условия. Отправьте тому же Лебедеву на Линч, он вам тоже отсыпет (если вообще сочтёт достойным взять). Предпроектная работа — это половина всего дела. Здесь она практически не проведена.


        1. ionicman
          04.12.2015 16:54

          а мелкие элементы интерфейса нет

          Для этого есть увеличение масштаба, если уж они мелкие.
          Нормально будет видно вполне.

          Можно реагировать на ввод цифр, к примеру. В идеале, представление даты само должно меняться при вводе в поле ввода.

          Да, при вводе полной даты с клавы, контрол должен рефрешнуться. Я так понимаю, что автор просто это еще не успел допилить, ибо делается на раз-два через oninput/onchange и прасинг даты.

          Я помню

          А я помню Корветы, ДВК и Роботроны. Но, по-моему, это все давно пора забыть, не? :)))
          Тачи на маках охерительные (хоть я маки и не люблю), в старик не пробовал, в вов аренить вполне себе получалось.

          Голосовой ввод бывает — но опять-же отдельная узкая тема — не мешайте все в одну кучу.
          Или Вы часто на сайтах контролы с голосовым вводом встречаете-используете?

          Отправьте тому же Лебедеву на Линч

          Это когда это Тёма стал мерилом? Не смешите меня. Кроме того что он был первый и матершинник — на этом его сильные стороны заканчиваются.

          Предпроектная работа — это половина всего дела. Здесь она практически не проведена.

          Человек сделал и постарался побыстрее опубликовать. Я его вполне понимаю.
          Да и трэнд это такой, начиная от игрушек, к которым потом выходят многомегобайтные патчи и заканчивая литературой.
          Это не сильно страшно — ну чесались у него руки, бывает — допилит в ближайшее время.

          Вы-ж Бетезду не ругаете за это? :D


  1. oENDark
    02.12.2015 18:31

    Есть ли возможность после выбора даты вставить выбранное значение куда-нибудь в другой input в виде unix timestamp?


    1. t1m0n
      02.12.2015 22:51

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


  1. to0n1
    03.12.2015 00:58

    Выглядит приятно! Я тоже плюсую использование moment.js


  1. tas
    03.12.2015 11:40

    Когда-то тоже писал универсальный календарь для всех своих проектов :)

    При желании двигаться дальше — добавил возможные варианты развития Вашего решения.

    Про диапазон и клавиатуру уже писали выше. Вот еще, что мне многократно пригодилось в моих проектах:

    1) Возможность указания допустимого периода для выбора даты.
    2) Возможность указания дат исключений, не допустимых для выбора (как конкретной даты, например, какого-нибудь праздника, так и в виде дня недели, например — субботы, воскресенья).
    3) Возможность указания списка дат, допустимых для выбора (данная фича нужна, если, например, нужно выбрать, кого-то с режимом работы «сутки, через трое» или «последняя пятница месяца»).


  1. gene4000
    03.12.2015 11:45

    А что-нибудь типа такого бывает?

    http://i74.fastpic.ru/big/2015/1203/21/9114b76a96fdee3fa796e0c9e2d63721.png

    Для профессионального использования, когда надо много дат вводить, чтобы он постоянно был на экране, а не появлялся по клику, чтобы ускорить процесс выбора дат.