Верстаем flex-календарик +11


Идет 2018 год, модные пацаны давно уже верстают на grid, а я все на третьем бутстрапе сижу с col-md кочерячусь, мельком поглядывая на четвертый.


Решил я, что это не дело, и стоит немного знания освежить, но у grid вроде как поддержка пока хромает, а вот flex технологию уже даже утюги поддерживают.


Вот и решил его освоить. И процессом усвоения с вами поделится. В общем, будем верстать календарик на весь год.


Нам потребуется


  • vue
  • клей moment
  • и чуток flex

Результат будет выглядеть примерно вот так:



Так получилось, что я адепт vue, и поэтому для рендера буду использовать его. Для работы с датами буду использовать библиотечку moment, можно и без нее обойтись, но мне с ней привычнее.


Подготовка


Устанавливаем vue-cli, если у вас её еще нет:


npm install -g vue-cli

создаем проект на базе шаблона webpack-simple, я буду использовать scss (в основном для комментов), поэтому когда визард спросит вас


? Use sass? (y/N)

ответьте y(es), в общем запускаем:


vue-init webpack-simple calendar_flex
cd calendar_flex
npm install

добавим библиотечку moment.js


npm install -S moment

очищаем файлик App.vue


<template>

</template>

<script>
export default {
 name: 'app',
 data () {
   return {
   }
 }
}
</script>

<style>

</style>

Чтоб не показаться варваром, не будем просто верстать голый календарь, а разработаем отдельную компоненту.


Создадим файлик Calendar.vue:


<template>
 <div>Календарь</div>
</template>

<style lang="scss" scoped>

</style>

<script>
 export default {
   props: {
     year: {  // год на который строится календарь
       type: Number,
       default: (new Date()).getFullYear()
     },
   },
   data () {
     return {}
   }
 }
</script>

подключим компоненту глобально в main.js


import Vue from 'vue'
import App from './App.vue'
import Calendar from './Calendar.vue'

Vue.component("calendar", Calendar);

new Vue({
 el: '#app',
 render: h => h(App)
})

добавим компоненту в App.vue


<template>
   <calendar></calendar>
</template>
...

Если все верно сделали, то увидим слово "Календарь" на белом фоне.


Готовим данные


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


{
   title: 'Январь',
   weeks: {1: {}, 2: {}, ...}
}

то бишь название месяца и массив из недель. Каждая неделя будет представлять собой объект где к каждому дню (от 1 до 7) будет привязана дата и может еще какая-нибудь мета информация:


week = {
   1: {date: new Date(), ...}, // понедельник
   2: {date: new Date(), ...}, // вторник
   ...
}

переключимся на файлик Calendar.vue, и обновим часть ответственную за скрипт:


import moment from 'moment';

export default {
 ...
 computed: {
   yearData() {
     let data = [];
     for (let m = 0; m < 12; ++m) {
       // формируем дату на первый день каждого месяца
       let day = moment({year: this.year, month: m, day: 1});

       let daysInMonth = day.daysInMonth(); // количество дней в месяце

       let month = { // готовим объект месяца
         title: day.format("MMMM"),
         weeks: {},
       };

       // итерируем по количеству дней в месяце
       for (let d = 0; d < daysInMonth; ++d) {
         let week = day.week();
         // небольшой хак, момент считает
         // последние дни декабря за первую неделю,
         // но мне надо чтобы считалось за 53
         if (m === 11 && week === 1) {
           week = 53
         }
         // если неделя еще не присутствует в месяце, то добавляем ее
         if (!month.weeks.hasOwnProperty(week)) {
           month.weeks[week] = {}
         }
         // добавляем день, у weekday() нумерация с нуля,
         // поэтому добавляю единицу, можно и не добавлять,
         // но так будет удобнее
         month.weeks[week][day.weekday() + 1] = {
           date: day.toDate(),
         };

         // итерируем день на единицу, moment мутирует исходное значение
         day.add(1, 'd');
       }

       // добавлям данные по месяцу в год
       data.push(month);
     }
     return data
   }
 }
 ...
}

Можно заглянуть в vue-devtools и увидеть там:



Верстаем


Ну давайте чего-нибудь уже выведем. Сначала научимся верстать один месяц, а потом, как освоимся, выведем все остальные. В общем, правим шаблон Calendar.vue:


<template>
 <div class="month">
   <div class="title">{{yearData[0].title}}</div>
   <div class="week" v-for="week in yearData[0].weeks">
     <div class="day" v-for="day in 7">
       <span v-if="week[day]">{{week[day].date.getDate()}}</span>
     </div>
   </div>
 </div>
</template>

Сначала заставим отображать даты в нашей неделе в ряд, для этого поправим стиль:


<style lang="scss" scoped>
 .week {
   display: flex;
 }
</style>


теперь укажем, что каждый день должен занимать одинаковое количество места в нашем ряду:


<style lang="scss" scoped>
 .week {
   display: flex;
 }
 .day {
   flex-grow: 1;
 }
</style>


Ну вроде поприличнее стало, только цифры таки скачут. Происходит это потому, что flex-grow по сути распределяет пустое пространство, а текст цифр в это пустое пространство не входит, поэтому, чтобы ячейки с цифрами стали действительно равными надо указать в стиле, чтобы ширина текста не учитывалась. Для этого установим свойству flex-basis на ноль.


Если не совсем понятно что я говорю, попробуйте поизучать данную картинку:


image


Ну как? Правим стиль:


<style lang="scss" scoped>
 .week {
   display: flex;
 }
 .day {
   flex-grow: 1;
   flex-basis: 0;
 }
</style>

от теперь красота



Я думаю мы теперь готовы к тому, чтобы попробовать вывести все месяцы, правим шаблон:


<template>
 <div class="year">
   <div class="month" v-for="month in yearData">
     <div class="title">{{month.title}}</div>
     <div class="week" v-for="week in month.weeks">
       <div class="day" v-for="day in 7">
         <span v-if="week[day]">{{week[day].date.getDate()}}</span>
       </div>
     </div>
   </div>
 </div>
</template>

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



Но нам этого мало, у нас календарь отображается в столбик, как завещал дедушка div, а нам бы в строчку… Сделаем по аналоги. Только что мы каждую неделю назначили flex контейнером для ее дней. А теперь наш блок year назначим flex контейнером для его месяцев. Добавим стили:


<style lang="scss" scoped>
 .week {...}
 .day {...}

 .year {
   display: flex;
 }

 .month {
   flex-grow: 1;
   flex-basis: 0;
 }
</style>

чет, каша какая-та:



причина сей каши в том, что по умолчанию flex не делает переносов, а пытается все отобразить в одну строчку, ну и соответственно сжимает покуда сил хватает, а их не хватает. Чтобы включить режим переносов, надо в нашем контейнер year добавить свойство flex-wrap, сделаем это:


<style lang="scss" scoped>
 .week {...}
 .day {...}

 .year {
   display: flex;
   flex-wrap: wrap; // добавили
 }

 .month {...}
</style>

Ну, эээ… типа получше стало, хотя б переносит:



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


Чтобы ужать, надо убрать flex-grow: 1 у month, (ток добавили, теперь удалять...), который отвечает за растяжение в рамках строки:


<style lang="scss" scoped>
 ...

 .month {
   flex-basis: 0;
 }
</style>


За то как будут располагаться последние два (на самом деле не только за них) висящих элемента отвечает justify-content в стиле контейнере, по умолчанию он равен flex-start. Можно выровнять в конец.


<style lang="scss" scoped>
 .week {...}
 .day {...}

 .year {
   display: flex;
   flex-wrap: wrap;
   justify-content: flex-end;  // выровнять в конец
 }

 .month {...}
</style>

Вот гифка с разными значениями:



Так как я планирую, что у меня будет всегда одинаковое количество месяцев в строке, и хочу чтобы они занимали все свободное место, то я пожалуй верну flex-grow: 1; обратно, и добавлю немного воздуха:


<style lang="scss" scoped>
 .week {...}

 .day {
   margin: 0.25em; // воздух
   flex-grow: 1;
   flex-basis: 0;
 }

 .year {
   display: flex;
   flex-wrap: wrap;
 }

 .month {
   margin: 0.25em; // воздух
   flex-basis: 0;
   flex-grow: 1; // вернул обратно
 }
</style>

красота:



Еще раз вернусь к justify-content и flex-grow: 1. Сравните две гифки, на первой у month flex-grow = 1, на второй — свойство отсутствует:




Какой вариант вам больше по душе, решайте сами.


Добавим строчку с днями недели. Сначала добавим вычислимое свойство в скрипт


 export default {
   ...
   computed: {
     weekDays () { // дни недели
       let days = [];
       for(let i = 1; i<=7;++i) {
         days.push(moment().isoWeekday(i).format("dd"))
       }
       return days;
     },
     ...
   }
 }

а теперь отобразим их в шаблоне:


<template>
 <div class="year">
   <div class="month" v-for="month in yearData">
     <div class="title">{{month.title}}</div>
     <div class="week">
       <div class="day" v-for="d in weekDays">
         <span>{{d}}</span>
       </div>
     </div>
     <div class="week" v-for="week in month.weeks">
       <div class="day" v-for="day in 7">
         <span v-if="week[day]">{{week[day].date.getDate()}}</span>
       </div>
     </div>
   </div>
 </div>
</template>


Я хочу чтобы воскресенье у меня было красненькое, давайте добавим динамический стиль к узлу .day:


<template>
 <div class="year">
   <div class="month" v-for="month in yearData">
     ...
     <div class="week" v-for="week in month.weeks">
       <div class="day" v-for="day in 7" :class="{[`week-day-${day}`]: true}">
         <span v-if="week[day]">{{week[day].date.getDate()}}</span>
       </div>
     </div>
   </div>
 </div>
</template>

А теперь подкорректируем стили, чуток красоты наведем:


<style lang="scss" scoped>
 .title { // новый стиль под название месяца
   margin: 0.25em;
   font-weight: bold;
 }

 .week-day-7 { // воскресенье
   color: red;
 }
 ...
</style>


Ну и последние штрихи: добавим возможность менять год и сделаем фиксированный заголовок средствами flex.


Переключимся на App.vue файл, и откорректируем шаблон:


<template>
 <div class="wrapper">
   <div class="content">
     <div class="header">
       <div class="title">{{year}}</div>
     </div>
     <div class="body">
       <calendar :year="year"></calendar>
     </div>
   </div>
 </div>
</template>

добавилась строчка с годом, пока, как видно, не фиксированная:



Подправим стили в App.vue, уберем отступы в body, установим высоту html и body на всю высоту окна, и сделаем заголовок покрасивше, я намеренно использую два узла style, один для глобальных стилей второй для локальных:


<style lang="scss">
 html {
   height: 100%;
 }

 body {
   height: 100%;
   margin: 0;
 }
</style>

<style lang="scss" scoped>
 .title {
   font-weight: bold;
   font-size: 1.5em;
   margin: 0.25em;
   text-align: center;
 }
</style>

Идея создания фиксированного заголовка на flex заключается в использовании двух вложенных контейнеров flex, один из которых ограничивает высоту всего содержимого, а второй, вложенный, использует flex-direction: column.


Правим стиль:


<style lang="scss" scoped>
 .title {...}

 .wrapper { // ограничивает высоту
   display: flex;
   height: 100%; // тут я указываю высоту по высоте родительского узла, в нашем случае 'это тег body
 }

 .content { // непосредственный контейнер с заголовком и содержимым
   display: flex;
   flex-direction: column;
 }

 .body { // основное тело контейнера
   flex-grow: 1; // растягивается, чтобы заполнить  все пространство
   overflow-y: auto; // скролл, если не влезает
 }
</style>


Классно, да? Вы можете даже сделать футер:


<template>
 <div class="wrapper">
   <div class="content">
     <div class="header">
       <div class="title">{{year}}</div>
     </div>
     <div class="body">
       <calendar :year="year"></calendar>
     </div>
     <div class="header">
       <div class="title">{{year}}</div>
     </div>
   </div>
 </div>
</template>


Ну и давайте кнопки для переключения года добавим:


<template>
 <div class="wrapper">
   <div class="content">
     <div class="header">
       <button @click="--year">&lt;</button>
       <div class="title">{{year}}</div>
       <button @click="++year">&gt;</button>
     </div>
     <div class="body">
       <calendar :year="year"></calendar>
     </div>
     <div class="header">
       <div class="title">{{year}}</div>
     </div>
   </div>
 </div>
</template>


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


<style lang="scss" scoped>
 .title {...}

 .header {
   padding: 0.25em;
   display: flex;
   justify-content: space-between;
 }

 .wrapper {...}
 .content {...}
 .body {...}
</style>


Хм… что-то тут не так. Чет наши заголовки прям сдавило и верстка поплыла. К сожалению это тот момент, который я не до конца понял почему так произошло. Но как я полагаю, это из-за того что display: flex задает динамическую высоту, и находясь внутри другого flex контейнера, ориентируется на размеры заданные своим родителем.


В общем, чтобы это вылечить, надо запретить flex контейнеру внутри которого находится наш header сжимать его размеры, для этого добавим свойство flex-shrink:


<style lang="scss" scoped>
 .title {...}

 .header {
   padding: 0.25em;
   display: flex;
   flex-shrink: 0; // не сжимай меня
   justify-content: space-between;
 }

 .wrapper {...}
 .content {...}
 .body {...}
</style>

Ну вот и все, теперь у вас есть flex-календарь на любой год!



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


Я надеюсь, что статья поможет тем, кто как и я застрял в css-временах где-то между 3-м и 4-м бутстрапом, сделать свои первые шаги навстречу современному css.


Код примера доступен по адресу.




К сожалению, не доступен сервер mySQL