Идет 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.


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

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


  1. argonavtt
    17.04.2018 15:36

    Не понимаю, а что тут особенного? Обычная вёрстка на обычных флексах. Читал и ждал чего то особенного, чуда не свершилось.


    1. Eugeny1987
      17.04.2018 15:41

      да вы что? тут же vue-js есть
      а по теме, вёрстка как вёрстка


  1. KayzerSoze
    17.04.2018 18:10

    Спасибо за статью. Можно к вам в компанию? Тоже мечтаю стать адептом vue. js


    1. SevenLines Автор
      17.04.2018 18:36

      Спасибо за спасибо! К сожалению, не являюсь представителем компании. Статью писал в рамках личных изысканий. Хотя я Vue и много использую, но для меня скорее как хобби, пока в основном удается использовать только в пет-проектах. А вы не мечтайте, а дерзайте)


  1. Yauheni85
    18.04.2018 10:43
    +1

    Я бы числа до 10 по правому краю выровнял. Режет глаз, когда цифры по левому краю...


    1. SevenLines Автор
      18.04.2018 10:48

      Согласен. Добавление


      .day {
        ...
        text-align: right; // или center
      }

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


  1. demimurych
    18.04.2018 16:52

    Мне показалось что Вы неправильно понимаете значения flex-basis.
    Я уже не первый раз встречаю как люди совершенно не понимают принципа работы flex-basis flex-grow и flex-shrink и хуже всего то, что это неправильное понимание качует из статьи в статью. Из за недопонимания этих принципов у многих людей верстка на флексах представляет из себя черную магию по манипулированию значениями: а что если так — ух ты, а если так — ух ты два.
    Та картинка что вы привели правильная но она отражает частный случай и скорее людей вводит в заблуждение.

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

    flex-grow и flex-shrink это не коэффициенты расширения или сжатия блока. Это коэффициенты как распределяется оставшееся (или нужное) пространство.
    Когда все элементы расставлены по основной линии может либо
    а) остаться свободное место не занятой блоками
    б) не хватать места чтобы все блоки разместить.
    Вычисляется сколько места остается (не хватает) и это место пропорционально значениям соответствующего параметра(flex-grow и flex-shrink) делится между элементами.

    То есть основное недопонимание происходит от того что люди думают будто flex-grow / shrink занимается сжатием или расширением всего блока, а они всего лишь определяют правила КАК распределяется свободное пространство ЕСЛИ оно есть или наоборот, на какой процент сожмется блок от необходимого пространства для отображения всех блоков. И в случае когда нет необходимости разрешать вопрос по распределению свободного или нужного пространства эти значения никаким образом не влияют на размер блоков потока.

    Пример:
    Ширина контейнера = 400px
    Ширина блока 1 flex-basis = 100px (flex-grow:100) (flex-shrink=500)
    Ширина блока 2 flex-basis = 50px (flex-grow:100) (flex-shrink=500)
    Ширина блока 3 flex-basis = 70px (flex-grow:100) (flex-shrink=500)
    Сумма = 220px
    Осталось свободного пространства 400 — 220 = 180 px

    Для каждого блока который имеет flex-grow выполнится следущая процедура:
    180 (оставшееся место) / 300 (сумма значений всех flex-grow grow потока) * flex-grow элемента = 60px
    то есть к каждому блоку добавится ровно 60 px вне зависимости от того, какого размера они были до этого.

    И наоборот если размер контейнера у нас был 100px а размер элементов тот же,
    выходит что 100 — 220 = — 120 не хватает для того чтобы разместить все блоки потока.

    120 / 1500 (сумма значений flex-shrink всех элементов потока) * flex-shrink 500( в нашем случае он одинаковый для всех = 40
    Поскольку у нас все блоки имеют одинаковое значение shrink то каждый блок уменьшиться ровно на 40 px
    Блок 1 = 60
    Блок 2 = 10
    Блок 3 = 40

    Так я думаю будет понятней.


    1. SevenLines Автор
      18.04.2018 17:29

      По поводу flex-basis: это я конечно не прав, что был неправильно понят, в моем случае был только текст, ну и мне проще всего было сказать, что имена размеры текста ломают верстку, а правильнее было сказать блоки с текстом. Поэтому, чтобы убрать учет размеров, надо было сделать flex-basis: 0, который по сути задает размер блока. Хотя справедливости ради я написал, что:


      flex-grow по сути распределяет пустое пространство

      А вот flex-shrink это для меня пока черная магия, это точно. Тут бы тоже хорошая картинка не помешала. Кстати может подскажете тогда почему когда добавил:


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

      (вторая с конца картинка), почему верстка поплыла, и пришлось юзать flex-shrink: 0. Кстати я проверил что если обернуть header простым div-ом, то, в моем случае, это равносильно использованию flex-shrink:0