Вы когда-нибудь видели календарь на веб-странице и думали, как, черт возьми, они это сделали? Для чего-то подобного было бы естественно обратиться к плагину или даже встроенному Календарю 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à, вот и все! Посмотрите финальную демонстрацию, чтобы увидеть все вместе.