Вы когда-нибудь видели календарь на веб-странице и думали, как, черт возьми, они это сделали? Для чего-то подобного было бы естественно обратиться к плагину или даже встроенному Календарю Google, но на самом деле сделать его намного проще, чем вы думаете.
Я установил демонстрацию на CodeSandbox, чтобы вы могли увидеть, к чему мы стремимся.
Давайте сначала определим некоторые требования к календарю. Должно:
- Отображение сетки по месяцам для данного месяца
- Отображение дат с предыдущего и следующего месяцев до, чтобы сетка всегда была заполнена
- Укажите текущую дату
- Показать название текущего выбранного месяца
- Переход к предыдущему и следующему месяцу
- Разрешить пользователю вернуться к текущему месяцу одним щелчком мыши
О, и мы сделаем это как одностраничное приложение, которое извлекает календарные даты из Day.js, сверхлегкой служебной библиотеки.
Шаг 1. Начните с базовой разметки
Начнем с создания базового шаблона для нашего календаря.
Мы можем выделить нашу разметку в виде трех слоев, где у нас есть:
- Раздел для заголовка календаря. В нем будут отображаться компоненты с текущим выбранным месяцем и элементы, отвечающие за разбиение на страницы между месяцами.
- Раздел для заголовка календарной сетки. Заголовок таблицы, содержащий список, содержащий дни недели, начиная с понедельника.
- Сетка календаря. Вы знаете, что каждый день текущего месяца представлен в виде квадрата в сетке.
Запишем это в файл под названием CalendarMonth.vue
. Это будет наша основная составляющая.
<!-- CalendarMonth.vue --> <template> <!-- Parent container for the calendar month --> <div class="calendar-month"> <!-- The calendar header --> <div class="calendar-month-header" <!-- Month name --> <CalendarDateIndicator /> <!-- Pagination --> <CalendarDateSelector /> </div> <!-- Calendar grid header --> <CalendarWeekdays /> <!-- Calendar grid --> <ol class="days-grid"> <CalendarMonthDayItem /> </ol> </div> </template>
Теперь, когда у нас есть некоторая разметка для работы, давайте сделаем еще один шаг и создадим необходимые компоненты.
Шаг 2: компоненты заголовка
В нашем заголовке есть два компонента:
- CalendarDateIndicator - показывает текущий выбранный месяц.
- CalendarDateSelector - отвечает за разбивку по месяцам.
Начнем с CalendarDateIndicator
. Этот компонент примет свойство selectedDate
, которое будет объектом Day.js, отформатирует его должным образом и покажет пользователю.
<!-- CalendarDateIndicator.vue --> <template> <div class="calendar-date-indicator">{{ selectedMonth }}</div> </template> <script> export default { props: { selectedDate: { type: Object, required: true } }, computed: { selectedMonth() { return this.selectedDate.format("MMMM YYYY"); } } }; </script>
Это было просто. Теперь давайте создадим компонент нумерации страниц по месяцам. В шаблоне он будет содержать три элемента, отвечающих за выбор предыдущего, текущего и следующего месяца. К каждому из этих элементов мы добавляем прослушиватель событий, который запускает соответствующий метод при щелчке по элементу.
<!-- CalendarDateSelector.vue --> <template> <div class="calendar-date-selector"> <span @click="selectPrevious"><</span> <span @click="selectCurrent">Today</span> <span @click="selectNext">></span> </div> </template>
Затем в разделе сценария мы настраиваем два свойства, которые будет принимать компонент:
currentDate
, который позволит нам вернуться к текущему месяцу при нажатии кнопки «Сегодня».selectedDate
, который сообщит нам, какой месяц выбран в данный момент.
Также мы определим методы, отвечающие за вычисление новой выбранной даты на основе текущей выбранной даты, используя методы Day.js subtract и add. Каждый метод также отправит $ событие родительскому компоненту с новым выбранным месяцем. Это позволит нам сохранить значение выбранной даты в одном месте, которое будет нашим CalendarMonth.vue
компонентом, и передать его всем дочерним компонентам (заголовок, календарная сетка).
<!-- CalendarDateSelector.vue --> <script> import dayjs from "dayjs"; export default { name: "CalendarDateSelector", props: { currentDate: { type: String, required: true }, selectedDate: { type: Object, required: true } }, methods: { selectPrevious() { let newSelectedDate = dayjs(this.selectedDate).subtract(1, "month"); this.$emit("dateSelected", newSelectedDate); }, selectCurrent() { let newSelectedDate = dayjs(this.currentDate); this.$emit("dateSelected", newSelectedDate); }, selectNext() { let newSelectedDate = dayjs(this.selectedDate).add(1, "month"); this.$emit("dateSelected", newSelectedDate); } } }; </script>
Теперь вернемся к компоненту CalendarMonth.vue
и воспользуемся только что созданными компонентами.
Чтобы использовать их, нам сначала нужно импортировать и зарегистрировать компоненты, а также нам нужно создать значения, которые будут передаваться в качестве свойств этим компонентам:
today
- сегодняшняя дата в правильном формате, используется в качестве значения для кнопки нумерации страниц "Сегодня".selectedDate
- текущая выбранная дата (изначально установлена сегодняшняя дата)
Последнее, что нам нужно сделать, прежде чем мы сможем визуализировать компоненты, - это создать метод, который будет отвечать за изменение значения selectedDate
. Этот метод будет запущен при получении события от компонента разбивки на страницы.
<!-- CalendarMonth.vue --> <script> import dayjs from "dayjs"; import CalendarDateIndicator from "./CalendarDateIndicator"; import CalendarDateSelector from "./CalendarDateSelector"; export default { name: "CalendarMonth", components: { CalendarDateIndicator, CalendarDateSelector }, data() { return { selectedDate: dayjs(), today: dayjs().format("YYYY-MM-DD") }; }, methods: { selectDate(newSelectedDate) { this.selectedDate = newSelectedDate; } } }; </script>
Сделав это, мы, наконец, можем отобразить заголовок нашего календаря.
<!-- CalendarMonth.vue --> <template> <div class="calendar-month"> <div class="calendar-month-header"> <CalendarDateIndicator :selected-date="selectedDate" class="calendar-month-header-selected-month" /> <CalendarDateSelector :current-date="today" :selected-date="selectedDate" @dateSelected="selectDate" /> </div> </div> </template>
Это хорошее место, чтобы остановиться и посмотреть, что у нас есть на данный момент.
Итак, у нас есть заголовок календаря, давайте продолжим и создадим компоненты для нашей календарной сетки.
Шаг 3. Компоненты сетки календаря
Здесь мы снова имеем два компонента:
- CalendarWeekdays - показывает названия дней недели.
- CalendarMonthDayItem - представляет отдельный день в календаре.
Компонент CalendarWeekdays содержит список, который перебирает метки дней недели (с использованием директивы v-for) и отображает эту метку для каждого дня недели. В разделе сценария нам нужно определить наши дни недели и создать вычисляемое свойство, чтобы сделать его доступным в шаблоне и кэшировать результат, чтобы не пересчитывать его в будущем.
<!-- CalendarWeekdays.vue --> <template> <ol class="day-of-week"> <li v-for="weekday in weekdays" :key="weekday">{{ weekday }}</li> </ol> </template> <script> const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; export default { name: 'CalendarWeekdays', computed: { weekdays() { return WEEKDAYS } } } </script>
CalendarMonthDayItem - это элемент списка, который получает свойство day
, которое является объектом, и логическое свойство isToday
, которое позволяет нам правильно стилизовать элемент списка для сегодняшней даты. У нас также есть одно вычисляемое свойство, которое форматирует полученный объект дня в желаемый формат.
<!-- CalendarMonthDayItem.vue --> <template> <li class="calendar-day" :class="{ 'calendar-day--not-current': !isCurrentMonth, 'calendar-day--today': isToday }" > <span>{{ label }}</span> </li> </template> <script> import dayjs from "dayjs"; export default { name: "CalendarMonthDayItem", props: { day: { type: Object, required: true }, isCurrentMonth: { type: Boolean, default: false }, isToday: { type: Boolean, default: false } }, computed: { label() { return dayjs(this.day.date).format("D"); } } }; </script>
Хорошо, теперь, когда у нас есть эти два, давайте вернемся к нашему CalendarMonth
компоненту и посмотрим, как мы можем их использовать.
Опять же, чтобы использовать новые компоненты, нам сначала нужно импортировать и зарегистрировать их. Нам также необходимо создать вычисляемое свойство, которое будет возвращать массив объектов, представляющих наши дни. Каждый день содержит date
собственность и isCurrentMonth
собственность.
<!-- CalendarMonth.vue --> <script> import dayjs from "dayjs"; import CalendarMonthDayItem from "./CalendarMonthDayItem"; import CalendarWeekdays from "./CalendarWeekdays"; export default { name: "CalendarMonth", components: { ... CalendarMonthDayItem, CalendarWeekdays }, computed: { days() { return [ { date: "2020-06-29", isCurrentMonth: false }, { date: "2020-06-30", isCurrentMonth: false }, { date: "2020-07-01", isCurrentMonth: true }, { date: "2020-07-02", isCurrentMonth: true }, ... { date: "2020-07-31", isCurrentMonth: true }, { date: "2020-08-01", isCurrentMonth: false }, { date: "2020-08-02", isCurrentMonth: false } ]; } } }; </script>
Затем в шаблоне мы можем визуализировать наши компоненты. Снова мы используем директиву v-for для рендеринга необходимого количества элементов дня.
<!-- CalendarMonth.vue --> <template> <div class="calendar-month"> <div class="calendar-month-header"> ... </div> <CalendarWeekdays/> <ol class="days-grid"> <CalendarMonthDayItem v-for="day in days" :key="day.date" :day="day" :is-today="day.date === today" /> </ol> </div> </template>
Хорошо, теперь это начинает выглядеть хорошо, посмотрите, где мы находимся в CodeSandbox.
Выглядит неплохо, но, как вы, наверное, заметили, на данный момент шаблон содержит только статические данные. Месяц жестко запрограммирован как июль, и числа дней также жестко запрограммированы. На следующем шаге мы изменим это, вычислив, какая дата должна отображаться в конкретном месяце. Давайте погрузимся в код!
Шаг 4. Настройка календаря на текущий месяц
Давайте подумаем, как мы можем рассчитать, какое число должно отображаться в конкретном месяце. Вот тут-то и вступает в игру Day.js. Он предоставляет все данные, необходимые для правильного размещения дат в правильные дни недели для данного месяца, используя данные реального календаря. Это позволяет нам получить и установить что угодно, от начальной даты месяца до всех параметров форматирования даты, которые нам нужны для отображения данных.
Мы будем:
- Получить текущий месяц
- Рассчитайте, где нужно разместить дни (будние дни)
- Рассчитать дни для отображения дат из предыдущего и следующего месяцев
- Объедините все дни в единый массив
Day.js уже импортирован в наш CalendarMonth
компонент. Мы также собираемся обратиться за помощью к паре плагинов Day.js. WeekDay помогает нам установить первый день недели. Некоторые предпочитают воскресенье как первый день недели. Другие предпочитают понедельник. Черт возьми, в некоторых случаях имеет смысл начать с пятницы. Начнем с понедельника.
Плагин weekOfYear возвращает числовое значение для текущей недели из всех недель в году. В году 52 недели, поэтому мы бы сказали, что неделя, начинающаяся 1 января, является первой неделей года и так далее.
Итак, вот что мы вложили в CalendarMonth.vue
<!-- CalendarMonth.vue --> <script> import dayjs from "dayjs"; import weekday from "dayjs/plugin/weekday"; import weekOfYear from "dayjs/plugin/weekOfYear"; ... dayjs.extend(weekday); dayjs.extend(weekOfYear); ...
Это было довольно просто, но теперь начинается самое интересное, поскольку мы теперь поиграем с календарной сеткой. Давайте на секунду остановимся и подумаем, что нам действительно нужно сделать, чтобы понять это правильно.
Во-первых, мы хотим, чтобы номера дат попадали в правильные столбцы дней недели. Например, 1 июля 2020 года - среда. Здесь должна начинаться нумерация дат.
Если первое число месяца выпадает на среду, это означает, что у нас будут пустые элементы сетки для понедельника и вторника на первой неделе. Последний день месяца - 31 июля, приходится на пятницу. Это означает, что суббота и воскресенье будут пустыми в последнюю неделю сетки. Мы начали заполнять их конечной и начальной датами предыдущего и следующего месяцев соответственно, чтобы календарная сетка всегда была заполнена.
Создать дни для текущего месяца
Чтобы добавить дни текущего месяца в сетку, нам нужно знать, сколько дней существует в текущем месяце. Мы можем получить это, используя метод daysInMonth
, предоставляемый Day.js. Давайте создадим для этого вычисляемое свойство.
<!-- CalendarMonth.vue --> computed: { ... numberOfDaysInMonth() { return dayjs(this.selectedDate).daysInMonth(); } }
Когда мы это знаем, мы создаем пустой массив, длина которого равна количеству дней в текущем месяце. Затем мы map()
этот массив и создаем объект дня для каждого из них. Создаваемый нами объект имеет произвольную структуру, поэтому при необходимости вы можете добавить другие свойства.
Однако в этом примере нам нужно свойство date
, которое будет использоваться для проверки того, является ли конкретная дата текущим днем. Мы также вернем isCurrentMonth
значение, которое проверяет, находится ли дата в текущем месяце или вне его. Если он находится за пределами текущего месяца, мы будем стилизовать их, чтобы люди знали, что они находятся за пределами диапазона текущего месяца.
<!-- CalendarMonth.vue --> computed: { ... currentMonthDays() { return [...Array(this.numberOfDaysInMonth)].map((day, index) => { return { date: dayjs(`${this.year}-${this.month}-${index + 1}`).format("YYYY-MM-DD") isCurrentMonth: true }; }); }, }
Добавить даты предыдущего месяца в календарную сетку
Чтобы получить даты из предыдущего месяца для отображения в текущем месяце, нам нужно проверить, какой день недели является первым днем в выбранном месяце. Вот где мы можем использовать плагин WeekDay для Day.js. Давайте создадим для этого вспомогательный метод.
<!-- CalendarMonth.vue --> methods: { ... getWeekday(date) { return dayjs(date).weekday(); }, }
Затем, исходя из этого, нам нужно проверить, какой день был последним понедельником в предыдущем месяце. Нам нужно это значение, чтобы знать, сколько дней с предыдущего месяца должно быть видно в представлении текущего месяца. Мы можем получить это, вычтя значение дня недели из первого дня текущего месяца. Например, если первый день месяца - среда, нам нужно вычесть 3 дня, чтобы получить последний понедельник предыдущего месяца. Наличие этого значения позволяет нам создавать массив объектов дня, начиная с последнего понедельника предыдущего месяца до конца этого месяца.
<!-- CalendarMonth.vue --> computed: { ... previousMonthDays() { const firstDayOfTheMonthWeekday = this.getWeekday(this.currentMonthDays[0].date); const previousMonth = dayjs(`${this.year}-${this.month}-01`).subtract(1, "month"); const previousMonthLastMondayDayOfMonth = dayjs(this.currentMonthDays[0].date).subtract(firstDayOfTheMonthWeekday - 1, "day").date(); // Cover first day of the month being sunday (firstDayOfTheMonthWeekday === 0) const visibleNumberOfDaysFromPreviousMonth = firstDayOfTheMonthWeekday ? firstDayOfTheMonthWeekday - 1 : 6; return [...Array(visibleNumberOfDaysFromPreviousMonth)].map((day, index) = { return { date: dayjs(`${previousMonth.year()}-${previousMonth.month() + 1}-${previousMonthLastMondayDayOfMonth + index}`).format("YYYY-MM-DD"), isCurrentMonth: false }; }); } }
Добавить даты следующего месяца в календарную сетку
Теперь давайте сделаем обратное и посчитаем, в какие дни следующего месяца нам нужно заполнить сетку текущего месяца. К счастью, мы можем использовать тот же помощник, который мы только что создали для расчета за предыдущий месяц. Разница в том, что мы рассчитаем, сколько дней следующего месяца должно быть видно, вычтя это числовое значение дня недели из 7.
Так, например, если последний день месяца - суббота, нам нужно вычесть 1 день из 7, чтобы построить массив дат, необходимых для следующего месяца (воскресенья).
<!-- CalendarMonth.vue --> computed: { ... nextMonthDays() { const lastDayOfTheMonthWeekday = this.getWeekday(`${this.year}-${this.month}-${this.currentMonthDays.length}`); const nextMonth = dayjs(`${this.year}-${this.month}-01`).add(1, "month"); const visibleNumberOfDaysFromNextMonth = lastDayOfTheMonthWeekday ? 7 - lastDayOfTheMonthWeekday : lastDayOfTheMonthWeekday; return [...Array(visibleNumberOfDaysFromNextMonth)].map((day, index) => { return { date: dayjs(`${nextMonth.year()}-${nextMonth.month() + 1}-${index + 1}`).format("YYYY-MM-DD"), isCurrentMonth: false }; }); } }
Хорошо, мы знаем, как создать все дни, которые нам нужны, давайте использовать их и объединить все дни в единый массив всех дней, которые мы хотим отобразить в текущем месяце, включая даты-заполнители из предыдущего и следующего месяцев.
<!-- CalendarMonth.vue --> computed: { ... days() { return [ ...this.previousMonthDays, ...this.currentMonthDays, ...this.nextMonthDays ]; }, }
Voilà, вот и все! Посмотрите финальную демонстрацию, чтобы увидеть все вместе.