Поддержка диапазонов дат в jQuery UI Datepicker

23 Августа 2010 — Иван
jQuery
16
комментариев

Как известно, одним из самых лучших календариков или, как их называют, datepicker’ов, является стандартный джейкверевский. В нем есть множество возможностей, полная кастомизация оформления, поддержка стандартных тем jQuery UI и другие полезные вещи. Но очень не хватает одного — возможности выбрать не конкретную дату, а целый диапазон.

Причем раньше в этом календарике была такая возможность, но начиная с jQuery UI 1.7 ее оттуда убрали. На одном англоязычном форуме я прочел переписку человека, решающего такую же проблему, с программистом, работающим над jQuery UI. Он ответил, что сделали они это во время рефакторинга календарика с целью его упрощения и, вероятно, когда-нибудь снова вернут эту возможность. Будем надеяться, что это случится скоро. Нам же надо как-то решить имеющуюся задачу и получить возможность выбора периодов в календаре.

Для решения этой задачи у нас есть 3 пути:

  1. Использовать jQuery UI версии ниже, чем 1.7. Если вы не используете новые компоненты этого UI, то это вполне приемлемый вариант.
  2. Использовать другой календарь, очень похожий на оригинальный — jQuery Datepicker. Схожесть объясняется тем, что этот календарь основан на оригинальном, но, на определенном этапе, отделился от него, так как разработчики jQuery UI, как я уже писал, хотели максимально простой календарь, а авторы jQuery Datepicker наоборот, хотели календарь с максимальным функционалом. Календарь, на самом деле, очень хороший и удобный. В нем так же легко можно настроить внешний вид, имеется поддержка абсолютно всех возможностей jQuery UI Datepicker, да и весит он немного. А главное — он изначально поддерживает периоды. Если у вас в проекте вообще не используется jQuery UI, то этот календарь — оптимальный выбор для вас. Хотя бы потому, что для стандартного календарика надо подключать всю тяжеленную UI, а для этого требуется только сам jQuery и больше ничего.
  3. Если у вас в проекте уже используется jQuery UI с какими-нибудь красивыми темами и хочется, чтобы все элементы выглядели единообразно, то остается последний и самый долгий вариант — сделать так, чтобы в стандартном календарике все же можно было бы использовать периоды. О том как это лучше сделать и рассказывает данная статья.

Подключение функционала

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

Итак, нужное нам событие — это «onSelect», которое возникает, когда мы кликаем по дате. Но изначально по нему происходит сбрасывание выделения всех ячеек и закрашивание выбранной. Нас это устраивает только для первого клика. Для второго же логика работы должна быть несколько другая. Чтобы не дать календарю на втором клике сбросить все выделения, воспользуемся таймером с задержкой в 1мс и закрасим нужный нам диапазон. При этом реально сбрасывание произойдет, просто наша функция сработает после этого и перекрасит все так быстро, что визуально это будет не заметно. Да, бесспорно, решение не очень красивое, но других альтернатив в данном случае я не вижу. В примере для этого я воспользуюсь джейкверевским плагином jQuery Timers, а вы, при желании, можете пользоваться встроенными функциями javascript.

Выделение интервала

Теперь переходим к самому главному — выделению интервала. Так как для этого нам понадобится несколько функций и переменных, то логичнее всего будет вынести все их в отдельный объект. Я назвал этот синглтон datepickerRange. Он содержит следующие свойства:

  • startDate — начальная дата интервала (объект Date);
  • endDate — конечная дата (объект Date);
  • currentDate — последняя выбранная дата (объект Date);
  • selectCount — количество кликов по календарю (целое число от 0 до 2). По этому свойству мы определяем, сколько раз кликнули и когда надо отображать интервал, а когда сбрасывать старое выделение.

Также в объекте есть методы:

  • checkDays() — пробегает по всем ячейкам текущего месяца и передает их функции checkDay.
  • checkDay(elem, currentPos) — принимает элемент и его номер в календаре. Сравнивает дату для ячейки с startDate и endDate и если ячейка попадает в промежуток между ними, то закрашивает его добавлением класса ui-state-active и удаляет класс ui-state-highlight. Последний отвечает за подсветку сегодняшнего дня и если вам наоборот надо, чтобы текущий день не сливался с интервалом, а всегда был выделен, то не удаляйте этот класс.
  • getSelectedDate(inst) — вспомогательный метод, который получает инстанцированный объект календаря и возвращает для него объект Date, соответствующий текущей выбранной дате.

Настройка календаря

Итак, все функции описаны и осталось только добавить небольшой код в события onSelect и onChangeMonthYear нашего календарика.

В событии onSelect мы каждый раз увеличиваем datepickerRange.selectCount на единицу. И если он равен 1, то просто запоминаем текущую дату в datepickerRange.startDate. А если 2 — то смотрим, какая дата у нас раньше и записываем ее либо в datepickerRange.startDate (который в этом случае становится уже datepickerRange.endDate), либо в datepickerRange.endDate. Таким образом, календарик работает для выделений диапазона как от ранней даты к более поздней, так и от поздней к более ранней. Ну а после того, как мы записали результаты в нужные переменные, то просто вызываем datepickerRange.checkDays(), которая с задержкой в 1 мс проверит все ячейки и закрасит нужные.

Но как быть, если мы поменяли год или месяц? Ведь все действия то у нас записаны только для события onSelect и выделенный диапазон сотрется. А очень просто — для события onChangeMonthYear, которое, как следует из названия, срабатывает при смене года или месяца, мы так же будем вызывать нашу функцию datepickerRange.checkDays(). Не забывая, при этом, присвоить переменной datepickerRange.currentDate текущую дату.

Также, для удобства, будет полезно писать выбранный интервал в каком-нибудь элементе. В нашем примере это <div id="#dateText">. Принцип работы очень простой: если onSelect срабатывает первый раз, то присваиваем нашему элементу значение переменной dateText, а если второй раз — то либо добавляем новую дату перед содержанием элемента, либо после него.

Листинг

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/*
	Singleton для работы с интервалами дат
*/
var datepickerRange = {
  startDate:null, 
  endDate:null, 
  currentDate:new Date(), 
  selectCount:0,
	
  checkDays: function(){
    var self = this;
    if(this.startDate&&this.endDate){
      $("#datepicker").oneTime(1, function() {
        $('td>a.ui-state-default').each(function (i) {
          self.checkDay(this, i);
        });
      });
    }
  },
	
  checkDay: function(elem, currentPos){
    var currentDay=currentPos+1;
    var currentDate = new Date(this.currentDate.getFullYear(), 
    this.currentDate.getMonth(), currentDay);
    if(currentDate.getTime()>=this.startDate.getTime()&&
    currentDate.getTime()<=this.endDate.getTime()){
      $(elem).addClass('ui-state-active').removeClass('ui-state-highlight');	
    }
  },
	
  getSelectedDate: function(inst){
    return new Date(inst.selectedYear, inst.selectedMonth, inst.selectedDay);
  }
};
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/*
	Инициализация календаря
*/
$(document).ready(function() {

var $datepicker = $("#datepicker");
$datepicker.datepicker({
  firstDay : "1",
  dateFormat: 'dd.mm.yy',
  "onSelect": function (dateText, inst) {			
    datepickerRange.selectCount++;
    datepickerRange.currentDate = datepickerRange.getSelectedDate(inst);
			
    if(datepickerRange.selectCount<2){
      datepickerRange.startDate = datepickerRange.getSelectedDate(inst);
      datepickerRange.endDate = null;
      $("#dateText").html(dateText);
    }else{
      datepickerRange.selectCount = 0;
      datepickerRange.endDate = datepickerRange.getSelectedDate(inst);
      if(datepickerRange.startDate.getTime()>datepickerRange.endDate.getTime()){
        datepickerRange.endDate = datepickerRange.startDate;
        datepickerRange.startDate = datepickerRange.currentDate;	
        $("#dateText").prepend(dateText+' - ');
      }else{
        $("#dateText").append(' - '+dateText);
      }
      datepickerRange.checkDays();
    }
  },
  onChangeMonthYear: function(year, month, inst) {
  datepickerRange.currentDate = datepickerRange.getSelectedDate(inst);
    datepickerRange.checkDays();
  }
});

});

Заключение

Как видите, все оказалось не так сложно. Что смущает меня в таком решении — так это применение таймера. Я не нашел другого варианта подключить свой функционал к событиям onSelect и onChangeMonthYear. Если вы знаете, как это сделать лучше — пишите в комментарии и я обязательно внесу ваш способ в статью. И, напоследок, демонстрация возможностей нашего календаря.

Комментарии (16) написать комментарий
  • 11:59, 11 Декабря 2010 — Владимир Кузнецов
    onSelect можно сделать гораздо проще. У экземпляра Datepicker есть флаг stayOpen, который по мимо ожидаемого действия имеет очень приятный побочный эффект. http://noteskeeper.ru/article/date-range-picker/
    ответить
    • 12:35, 7 Августа 2011 — Антон
      решение со stayOpen еще актуально или этот функционал тоже урезали?
      может, я что-то делаю не так, но календарь все равно исчезает при выборе первой даты.
      ответить
      • 18:43, 18 Сентября 2012 — Сергей
        Похоже, урезали. Эффект тот-же - календарь исчезает при выборе первой даты
        ответить
  • 19:23, 15 Декабря 2010 — guest
    http://datepicker.org.ru/
    ответить
  • 22:22, 12 Июня 2011 — Гость
    Большое спасибо, все работает.
    ответить
  • 12:42, 21 Июня 2012 — Denys
    Спасибо, но если например опция numberOfMonths будет 3, то работает некорректно.
    ответить
  • 10:34, 5 Июля 2012 — Игорь
    Как сделать так. чтобы это все было привязано к инпуту?.

    Т.Е. есть поле, кликаешь по нему- появляется календурь, выбираешь даты, и календурь пропадает???

    На самом деле сейчас для меня этот вопрос очень актуален
    ответить
    • 16:09, 21 Августа 2012 — Иван
      А в чем проблема? Просто отслеживайте событие клика по инпуту, а как оно произошло - выводите календарь. После же выбора двух дат - прячьте календарь. Все эти события легко отслеживаются.
      ответить
  • 17:47, 24 Августа 2012 — thekip
    Отредактировал плагин.

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

    Плюс добавил поддержку опции numberOfMonths. Т.е. можно отображать сколько угодно месяцев одновременно и диапазон будет корректно работать. Кроме того корректно работает диапазон на стыке годов (когда одновременно видно и 2012 и 2013)

    Хотел так же это красиво упаковать в виджет, но к сожалению Datepicker почему то построен не на базе фабрики ui.widget и его невозможно расширить таким образом.
    Листинг ниже или на jsFiddle http://jsfiddle.net/NeNUj/

    var calendar = $('.datepicker');


    var datepickerRange = {
    startDate:null,
    endDate:null,
    currentDate:new Date(),
    selectCount:0,

    checkDays: function(datepicker){
    var self = this;

    if(this.startDate && this.endDate){
    setTimeout(function() {
    //обрабатываем для каждого месяца
    datepicker.dpDiv.find('.ui-datepicker-calendar').each(function(monthIndex){
    var calendar = $(this);
    var currMonth = datepicker.drawMonth+monthIndex; //Берем начальный месяц отрисовки, к нему прибавлям текущий месяц итерации (месяцы zero-based)
    var currYear = datepicker.drawYear;

    //Обработка стыка годов
    if (currMonth > 11) {
    currYear++;
    currMonth = datepicker.drawMonth - 12 + monthIndex; //magic ))
    }

    calendar.find('td>a.ui-state-default').each(function (dayIndex) {
    var day = dayIndex+1; //index is zero-based but days isn't
    self.checkDay(this, day, currMonth, currYear);
    });

    })


    }, 1);
    }
    },

    checkDay: function(elem, day, month, year){

    var date = new Date(year, month, day);

    if(date.getTime()>=this.startDate.getTime()&& date.getTime()<=this.endDate.getTime()){
    $(elem).addClass('ui-state-active').removeClass('ui-state-highlight');
    }
    },

    getSelectedDate: function(inst){
    return new Date(inst.selectedYear, inst.selectedMonth, inst.selectedDay);
    }
    };

    calendar.datepicker({
    numberOfMonths: 2,
    showButtonPanel: true,
    firstDay : "1",
    dateFormat: 'dd.mm.yy',
    "onSelect": function (dateText, inst) {

    datepickerRange.selectCount++;
    datepickerRange.currentDate = datepickerRange.getSelectedDate(inst);

    if(datepickerRange.selectCount<2){
    datepickerRange.startDate = datepickerRange.getSelectedDate(inst);
    datepickerRange.endDate = null;
    $("#dateText").html(dateText);
    }else{
    datepickerRange.selectCount = 0;
    datepickerRange.endDate = datepickerRange.getSelectedDate(inst);
    if(datepickerRange.startDate.getTime()>datepickerRange.endDate.getTime()){
    datepickerRange.endDate = datepickerRange.startDate;
    datepickerRange.startDate = datepickerRange.currentDate;
    $("#dateText").prepend(dateText+' - ');
    }else{
    $("#dateText").append(' - '+dateText);
    }
    datepickerRange.checkDays(inst);
    }
    return false;
    },
    onChangeMonthYear: function(year, month, inst) {
    datepickerRange.currentDate = datepickerRange.getSelectedDate(inst);
    datepickerRange.checkDays(inst);
    }
    });
    ответить
  • 17:49, 24 Августа 2012 — thekip
    Автор, можешь обновить код в тексте (думаю много людей нечитающих комменты будет благодарно), только не забудь указать имя автора изменений и ссылку на визитку visitka.thekip.ru
    ответить
  • 12:02, 21 Ноября 2012 — Дмитрий
    Большое спасибо за пост! Учень нужен. Использовал с небольшими дополнениями, например,
    var day = parseInt($(this).text() );
    вместо
    var day = dayIndex+1; //index is zero-based but days isn't
    ибо в случае disabled элементов индекс будет неверным
    ответить
    • 14:20, 22 Ноября 2012 — Иван
      Рад, что пост помог.
      ответить
    • 9:13, 9 Декабря 2012 — Ярослав
      Спасибо! и за пост и за var day = parseInt($(this).text() );
      ответить
  • 13:48, 8 Января 2013 — Алексей
    Отличная статья! Спасибо за труд.
    Однако заметил такую странную вещь, которую не пойму как решить:
    если мы выставляем в опциях календаря minDate: 0 для того, что бы он не давал выбирать даты прошлого периода, то выбор дат не работает. Точнее работает, но навешивание класса, который отвечает за визуальное изменение ячейки календаря неадекватное.
    ответить
    • 13:51, 8 Января 2013 — Алексей
      var day = parseInt($(this).text() );

      вот эта правка от Дмитрия решает подобную проблему.
      ответить
  • 23:45, 25 Мая 2013 — Борис
    То, что надо. Только не понятно как указать интервал при загрузке страницы.
    ответить
Написать комментарий