Как стать автором
Обновить

Адаптивные таблицы в вебе

Время на прочтение53 мин
Количество просмотров24K

Таблица — удобный и один из самых эффективных способов подачи ТЕКСТОВОЙ информации: на минимуме пространства размещено максимум данных. И что не менее важно — эти данные доступны не только для восприятия, но и для анализа (СРАВНЕНИЯ). Основная сложность таблиц при верстке — их адаптивность для устройств с небольшими экранами (мобильных девайсов). Можно ли сделать так, чтобы даже на экране с размерами в несколько сантиметров таблицы могли быть удобными для восприятия?

В статье будет предложен способ адаптации таблиц к разным экранам разных устройств. Кроме того, этот способ будет реализован на практике с применением Vue.js 3, CSS-модулей и компонентно-ориентированного подхода.

Содержание

  • Структура таблицы

  • Цветовая дифференциация

  • Проблема с Mobile First

  • Что делать с таблицами на маленьких экранах?

  • Back to the Future Forward to the Past, или что будем улучшать

  • Mobile First или Desktop First? Без разницы!

  • Подготовка к работе, Vue.js

  • Структура проекта и нейминг

  • CSS-модули

  • Компонентно-ориентированный подход

  • API компонента адаптивной таблицы

  • Классический вид таблицы

  • Делаем таблицу адаптивной

  • Если много колонок

  • Движущаяся шапка адаптивной таблицы

Структура таблицы

Таблица состоит из столбцов и строк. Первая, самая верхняя строка — шапка таблицы. Т.е. ЗАГОЛОВКИ столбцов. Важно отметить, что первый столбец, как правило, играет не менее важную роль, т.к. в его ячейках — информация, которую можно обозначить как ЗАГОЛОВКИ строк.

Таблица может быть усложнена (что, как ни парадоксально, не усложняет, а наоборот, упрощает ее восприятие):

  • шапка таблицы может состоять не из одной, а из нескольких строк, причем в каких-то столбцах эти строки могут быть объединены, а в каких-то — могут быть подстолбцы

  • кроме заголовков строк в первом столбце, во втором могут быть подзаголовки этих строк

  • для улучшения восприятия ячейки с одинаковыми данными могут быть объединены как по горизонтали, так и по вертикали

Пример таблицы с шапкой из двух строк, двумя заголовками строк (title + subtitle) и объединенными ячейками
Пример таблицы с шапкой из двух строк, двумя заголовками строк (title + subtitle) и объединенными ячейками

Цветовая дифференциация

Данные, обозначенные в ячейках, легче воспринимаются, если эти ячейки разделены границами. Не менее важный аспект — дифференциация разных элементов таблицы по цветам. Как правило, чаще всего выделяют другим цветовым фоном шапку таблицы, но не менее важно, особенно при адаптации таблицы для маленьких экранов (об этом ниже), выделять фоном заголовки строк.

Добавление цветного фона для шапки и заголовков строк делает таблицу более удобной для восприятия
Добавление цветного фона для шапки и заголовков строк делает таблицу более удобной для восприятия

При наличии границ восприятие информации также улучшает центрирование в ячейках. И еще один приятный для глаз момент — наличие отступов, по крайней мере вертикальных (оптимальный вариант, на мой взгляд — от 0.25em до 0.5em). Да, и при этом (наличии отступов) межстрочный интервал в ячейках не должен быть таким же большим, как у обычных параграфов.

Проблема с Mobile First

Основная проблема, почему таблицы исчезли с сайтов — очень небольшая ширина мобильных экранов, особенно в portrait-ориентации. Часто заголовки строк состоят из нескольких слов, и поэтому достаточно длинны, и все что остается — один-два столбца с данными, которые не умещаются в ширину экрана при нормальном (читаемом) размере шрифта.

Именно поэтому принцип “Mobile First”, популярный в адаптивном дизайне, сводит использование таблиц к нулю. Вместо них получил распространение “карточный подход”, когда все данные, которые можно было бы уместить в строку, представлены в виде списка.

Карточный подход, примененный к нашей таблице, который часто используется в адаптивной верстке, как его можно наблюдать на экранах мониторах и мобильных устройств в portrait-ориентации
Карточный подход, примененный к нашей таблице, который часто используется в адаптивной верстке, как его можно наблюдать на экранах мониторах и мобильных устройств в portrait-ориентации

Недостатков такого подхода множество. Самые главные из них:

  • карточки — это “мусор” в дизайне: на странице — множество повторяющихся элементов, которых могло бы и не быть (правая часть списков под подзаголовками Subtitles, которые мешают восприятию)

  • карточки занимают много места: на экране мобильного устройства видно очень немного карточек без скролла — две, максимум три; чтобы ознакомиться со всей информацией, нужно пролистывать страницу вверх или вниз

  • самое главное — теряется уникальное преимущество таблиц — анализ, СРАВНЕНИЕ данных, которые были до этого представлены в ячейках компактно, рядом друг с другом

Реальный пример «карточного подхода» — страница конфигуратора Mercedes модели C-класса (https://www.mercedes-benz.co.uk/passengercars/mercedes-benz-cars/car-configurator.html/motorization/CCci/GB/en/C-KLASSE/LIMOUSINE). В полноэкранном режиме на большом мониторе полностью видны только 6 карточек, на экран мобильного телефона с трудом за один раз помещаются только две. А всего их 19!
Реальный пример «карточного подхода» — страница конфигуратора Mercedes модели C-класса (https://www.mercedes-benz.co.uk/passengercars/mercedes-benz-cars/car-configurator.html/motorization/CCci/GB/en/C-KLASSE/LIMOUSINE). В полноэкранном режиме на большом мониторе полностью видны только 6 карточек, на экран мобильного телефона с трудом за один раз помещаются только две. А всего их 19!

Что делать с таблицами на маленьких экранах?

Есть один подход к таблицам, который мне нравится. Скорее всего кто-нибудь это придумал давным-давно, и я “изобрел велосипед”, просто не видел такого воплощения адаптивной верстки таблиц. Смысл подхода заключается в том, что на широких экранах пользователь видит классическую таблицу, а на узких — заголовки (и подзаголовки) строк трансформируются в эдакие “подшапки” таблицы.

В случае, когда заголовки и подзаголовки строк в мобильном отображении становятся «подшапками» таблицы, все преимущества таблицы сохраняются, и маленький размер экрана мобильного устройства этому не помеха
В случае, когда заголовки и подзаголовки строк в мобильном отображении становятся «подшапками» таблицы, все преимущества таблицы сохраняются, и маленький размер экрана мобильного устройства этому не помеха

Довольно теории и придуманных картинок, перейдем к реальным примерам. И, если вдруг их можно улучшить — попробуем сделать это, заодно потренируясь работать в моем любимом vue.js.

Forward to the Past, или что будем улучшать

Благодаря Генри Форду и его первой конвейерной линии, созданной в 1913 году, в производстве практически всех товаров появился конвейер. Подход Тойоты к организации процесса — канбан (1959 год) — стал основой для agile-методологии, которая очень популярна в IT-индустрии. Автопроизводители всегда стремились быть первооткрывателями во многих сферах (еще один показательный пример забыл — product placement с «Бондиадой» в кино). Ну или хотя бы брали на вооружение передовые технологии. Предлагаю в качестве примера рассмотреть то, что нам предлагают автомобильные бренды в интернете (примечание — с российским автобизнесом сейчас, мягко говоря... все не так однозначно, поэтому примеры я искал на сайтах .uk, потому что там английский язык)

Представим, что мы хотим купить новый автомобиль. Мы определились с маркой, моделью, и даже примерно представляем чем отличаются версии, в которых эта модель производится. Мы понимаем, двигатель какой мощности и объема нас устраивает, и какого типа коробку передач мы хотим. Наше желание — ПРИЦЕНИТЬСЯ, т.е. понять разброс цен в зависимости от перечисленных выше условий. У всех автопроизводителей есть конфигураторы, но, чтобы понять, насколько отличается цена, в этих конфигураторах нужно совершать множество действий, переходить по шагам/этапам и пр. Чтобы прицениться, определить порядок цен, есть один действенный инструмент — прайс-лист. Попробуем найти его на сайтах и посмотреть, можно ли его как-то улучшить.

Начать я решил с Volkswagen Passat, потому что когда-то ездил на такой машине. Но на английском оф. сайте этого бренда я вообще не увидел прайс-листов! Ок, новый Passat проектирует дизайн-команда Skoda, рассмотрим «младшего брата» из Чехии — Superb. Для сравнения рассмотрим американскую альтернативу — Ford Mondeo, и японскую — Toyota Corolla. Прайс-листы для этих моделей на английских сайтах нашлись, но! — все они почему-то представлены в формате pdf.

Две страницы (21-я и 22-я) pdf-файла (https://www.skoda.co.uk/_doc/71165c49-62c5-4f74-b29a-be6ad40d7373), на который ведет ссылка «BROCHURE & PRICELIST» со страницы https://www.skoda.co.uk/new-cars/superb/hatch-se
Две страницы (21-я и 22-я) pdf-файла (https://www.skoda.co.uk/_doc/71165c49-62c5-4f74-b29a-be6ad40d7373), на который ведет ссылка «BROCHURE & PRICELIST» со страницы https://www.skoda.co.uk/new-cars/superb/hatch-se
Две страницы (8-я и 9-я) pdf-файла (https://s3-eu-west-1.amazonaws.com/liveassets.toyotaretail.co.uk/price-list/010791_VPL_CVPL_JUNE_68_PAGES.pdf#page=8), на который ведет ссылка “View Brochure” со страницы https://www.toyota.co.uk/new-cars/corolla-hatchback
Две страницы (8-я и 9-я) pdf-файла (https://s3-eu-west-1.amazonaws.com/liveassets.toyotaretail.co.uk/price-list/010791_VPL_CVPL_JUNE_68_PAGES.pdf#page=8), на который ведет ссылка “View Brochure” со страницы https://www.toyota.co.uk/new-cars/corolla-hatchback
4-я страница прайс-листа из pdf-файла https://www.ford.co.uk/content/dam/guxeu/uk/documents/price-list/cars/PL-Mondeo_2019.pdf, доступного по ссылке “Brochures & Price Lists” со страницы https://www.ford.co.uk/cars/mondeo
4-я страница прайс-листа из pdf-файла https://www.ford.co.uk/content/dam/guxeu/uk/documents/price-list/cars/PL-Mondeo_2019.pdf, доступного по ссылке “Brochures & Price Lists” со страницы https://www.ford.co.uk/cars/mondeo

Я не представляю, как эти pdf-прайсы изучать на экране мобильного телефона с шириной <10 см. Мало того, даже если распечатать их на большом листе бумаге, можно заметить, что сделаны эти таблицы совсем не идеально. Попробуем использовать современные возможности веба. А в качестве рабочего примера я возьму Ford, отдав таким образом дань благодарности основателю компании за его замечательную книгу “Моя жизнь, мои достижения” (рекомендую к прочтению).

Чем меньше таблица, тем лучше

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

  • примечание к “CO₂ Emissions” гласит «...Эти цифры могут не отражать реальные результаты вождения... Для получения дополнительной информации... обратитесь к нашему разделу „Топливо и производительность“.» Раз это есть в другом разделе и не относится к стоимости — убираем столбец

  • примечания к столбцам “Monthly BIK” — “не для частных покупателей”, поэтому тоже их убираем

В отличие от pdf-формата, предназначенного для печати (я вообще поражаюсь, зачем в 21 веке этот формат до сих пор используется в сайтостроении), на веб-странице мы можем использовать интерактивность, что позволит нам существенно сократить количество ячеек.

  • разделим данные по категориям Bodystyle. Не совсем понятные значения “4 door” и “5 door” заменим на “Saloon” и “Hatchback” из той же брошюры, итоговые категории будут [‘Saloon / Hatchback’, ‘Estate’]

  • в каждой категории сделаем интерактивный выбор “Price: [‘Basic Retail’, ‘incl. Vat’, ’+ Manufacturers’]”

И немного переформатируем таблицу:

Для интерактивных выборов «Body: Saloon / Hatchback» и «Price: Basic Retail» таблица в классическом представлении может выглядеть так
Для интерактивных выборов «Body: Saloon / Hatchback» и «Price: Basic Retail» таблица в классическом представлении может выглядеть так

Mobile First или Desktop First? Без разницы!

У мобильных телефонов — разные размеры экранов. Планшеты — тоже мобильные устройства, и с их учетом количество вариантов возможных адаптаций стремится к бесконечности. А если учитывать и landscape-ориентацию мобильных девайсов, запутаться можно окончательно. Поэтому можно поступить проще — вообще не обращать на это внимание ? Всё, что нас интересует — ширина таблицы. Если все умещается — нет проблем, если не умещается — значит заголовки строк выносим в отдельные строки.

Подготовка к работе, Vue.js

Я буду использовать последнюю версию Vue.js (3 на момент написания статьи) и поэтапно покажу и расскажу, что буду делать. Весь код и коммиты, описанные здесь, можно посмотреть на Github.

Создадим проект согласно иструкции с сайта Vue.js. Ответы «Yes» — только в вопросах «Add ESLint for code quality?» и «Add Prettier for code formatting?». Настрою проект так, как мне удобно.

// vite.config.js, добавить внутрь export default defineConfig({})
// это нужно для CSS-модулей
css: {
  modules: {
    root: '.',
    localsConvention: 'camelCase',
  },
},
// .eslintrc.cjs, добавить внутрь module.exports {}
// если вам нравятся одинарные кавычки при форматировании через npm run lint
rules: {
  'prettier/prettier': ['error', { singleQuote: true }],
},

Добавим папку “css” в “src” и создадим там два файла (все пути в коде далее означают нахождение файлов внутри папки “src”

// ./css/reset.css
* {
  box-sizing: border-box;
  color: inherit;
  font-size: 100%;
  margin: 0;
  padding: 0;
  -webkit-tap-highlight-color: rgba(0,0,0,0);
}
// ./css/font-family.css, скопировать свойства из ./assets/base.css
body {
  font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI',
    Roboto, Oxygen, Ubuntu, Сantarell, 'Fira Sans', 'Droid Sans',
    'Helvetica Neue', sans-serif;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

Удаляем всё из папок “assets” и “components”. Изменяем App.vue:

// .App.vue
<template>
  <p>There will be an adaptive table here.</p>
</template>

<style>
@import './css/reset.css';
@import './css/font-family.css';

#app {padding: 1em 0;}
#app, body {min-height: 100%; width: 100%;}
</style>

Набираем в командной строке git init и делаем первый коммит “start project”.

Добавим media queries:

// ./css/media-queries.css
/* Mobile, Portrait */
@media (max-aspect-ratio: 6/5) {
  body {
    color: rgb(0, 0, 0);
    font-size: calc(12 * 100vw / 320);
  }
}
/* Mobile, Landscape */
@media (max-width: 1134px) and (min-aspect-ratio: 6/5) {
  body {
    color: rgb(0, 0, 1);
    font-size: calc(10 * 100vw / 480);
  }
}
/* Desktop */
@media (min-width: 1134px) and (min-aspect-ratio: 6/5) {
  body {
    color: rgb(0, 1, 0);
    font-size: calc(24 * 100vw / 1920);
  }
}
/* Wide (Ultra Wide) Monitors */
@media (min-width: 1134px) and (min-aspect-ratio: 5/2) {
  body {
    color: rgb(1, 0, 0);
    font-size: calc(22 * 100vw / 2560);
  }
}

// ./store-variables/media-queries.js
import { computed, ref } from 'vue';
const refMq = ref('');
const checkMq = () => {
  const { color } = getComputedStyle(document.body);
  let mq;
  if (color === 'rgb(0, 0, 0)') mq = 'portrait';
  else if (color === 'rgb(0, 0, 1)') mq = 'landscape';
  else if (color === 'rgb(1, 0, 0)') mq = 'wide';
  else mq = 'desktop';
  refMq.value = mq;
};
const setMq = () => {
  checkMq();
  window.addEventListener('resize', checkMq);
};
const isMqPortrait = computed(() => refMq.value === 'portrait');
const isMqLandscape = computed(() => refMq.value === 'landscape');
const isMqMobile = computed(() => isMqPortrait.value || isMqLandscape.value);
export { isMqPortrait, isMqLandscape, isMqMobile, setMq };

В файле «media-queries.css» я определил лейауты (отображения):

  • Mobile, Portrait

  • Mobile, Landscape

  • Desktop

  • Wide (Ultra Wide мониторы)

Выражение font-size: calc(24 * 100vw / 1920); означает, что в пределах одного лейаута шрифт будет растягиваться в зависимости от ширины экрана, а при ширине 1920px его размер будет равен 24px.

Отображения заданы через aspect-ratio и граничное значение 1134px. Согласно Mdn такое количество пикселей равно 30cm. Если вам не нравится такое разделение — используйте свое. Кроме того, в отображениях я задал разные цвета body. Эта разница неразличима для глаза, но с помощью нее можно в любой момент определить, какой из лейаутов в браузере пользователя интернета. Что и делает функция setMq() в файле «media-queries.js».

Изменяем App.vue:

// .App.vue, вставить в начало файла
<script setup>
import { setMq } from './store-variables/media-queries';

setMq();
</script>

// вставить внутрь <style> после всех импортов
@import './css/media-queries.css';

Все изменения до этого момента — в коммите «media queries».

Структура проекта и нейминг

В дополнение к папке “components”, которую ставит дефолтом Vue, мне нравится следующая структура проекта, которая определилась опытным путем:

  • “./css/” — папка для общих css-файлов и css-модулей. Модули мне удобно разделять по категориям, плюс бывают модули для каких-то конкретных компонентов (дальше будет понятно, о чем речь)

  • “./store-variables/” — папка для вычисляемых (computed) переменных Vue, причем только для тех, которые используются в нескольких компонентах; благодаря composition API в Vue 3 и подобной папке глобальные хранилища данных Vuex/Pinia уже не особо и нужны

  • “./store-constant/” — папка, в которой содержатся какие-то постоянные, неизменяемые данные

  • “./store-functions/” — папка функций, которые могут быть как независимыми, так и задействовать данные из “store-constant” или “store-variables”

Нейминг я использую такой:

  • domName = ref(null) — если dom-элемент

  • refName = ref(/* … */) — изменяемая-vue-переменная (аналогично reactiveName, но reactive я использую редко, ref мне нравятся больше)

  • getName = computed(() => refName.value) — геттер “refName”

  • isName — тоже, что “getName”, но с типом Boolean

  • setName() — функция, которая что-то устанавливает, в т.ч. и ref-переменные

  • switchName() — сеттер без аргумента, если ref-переменная — Boolean

  • seekName() — функция, которая что-то вычисляет и отдает (потому что getName уже занято)))

  • CommonName.vue — компонент-”черный ящик”, который можно использовать в других проектах (дальше будет понятнее)

Если компонент — большой (по размеру кода), то для него создается папка в “components” и он переезжает туда, на уровень ниже.

“st” — CSS-стили, “cl” — CSS-классы, “bt”, “br”, “bb”, “bl” — соответственно border-[top/right/bottom/left], также “pt” и т.д., “mt” и т.д. — padding-top и далее, margin-top и далее. Отступы (и паддинги, и маргины) соотносятся с заданными в root значениями:

:root {
  --gap: 0.5em;
  --gap-twice: 1em;
}
.pt {padding-top: var(--gap);}
.pr2 {padding-right: var(--gap-twice);}

Я сокращаю и другие названия CSS-свойств, но их описывать не буду — названия понятны из контекста.

CSS-модули

Нам нужно реализовать интерактивный выбор пользователя. Я скопировал данные из прайс-листа, слегка их преобразовал и сохранил в файл “./store-constant/table-data.js”. Файл можно скачать из коммита “table-data”.

Чтобы не перегружать главный компонент App.vue, создадим ButtonsForTable.vue и напишем в нем код, ориентируясь на семантическую верстку:

// ./components/ButtonsForTable.vue
<script setup>
import { headTitles, priceTypeNames } from '../store-constant/table-data';

const bodyTypes = Object.keys(headTitles);
const priceTypes = Object.values(priceTypeNames);
</script>

<template>
  <section>
    <h5>Body:</h5>
    <p>{{ bodyTypes }}</p>
  </section>

  <section>
    <h5>Price:</h5>
    <p>{{ priceTypes }}</p>
  </section>
</template>

Импортируем созданный компонент в App.vue:

// ./App.vue
// вставить внутрь <script setup>
import ButtonsForTable from './components/ButtonsForTable.vue';

// заменить <template>
<template>
  <ButtonsForTable />
</template>

Поработаем со стилями. Раньше я использовал SCSS, но теперь мне нравятся CSS-модули. Добавим в App.vue:

// .App.vue, вставить внутрь <style>
.flex, #app {display: flex;}
.wrap, #app {flex-wrap: wrap;}
.ac-start, #app {align-content: flex-start;}

Создадим CSS-модули:

// ./css/colors.module.css
:root {
  --color-grey: #8c8c8c;
}
.grey {color: var(--color-grey);}

// ./css/flex.module.css
.ac-start {composes: ac-start from global;} /* align-content: flex-start */
.ai-center {align-items: center;}
.flex {composes: flex from global;} /* display: flex */
.fb100 {flex-basis: 100%;}
.fg1 {flex-grow: 1;}
.wrap {composes: wrap from global;} /* flex-wrap: wrap */

// ./css/gaps.module.css
:root {
  --gap: 0.5em;
  --gap-twice: 1em;
}
.pb {padding-bottom: var(--gap);}
.pr {padding-right: var(--gap);}
.pt {padding-top: var(--gap);}
.pl2 {padding-left: var(--gap-twice);}
.pr2 {padding-right: var(--gap-twice);}

// ./css/text.module.css
.normal {font-weight: normal;}

И изменим ButtonsForTable.vue:

// ./components/ButtonsForTable.vue
<script setup>
import { computed, useCssModule } from 'vue';
import flex from '../css/flex.module.css';
import gaps from '../css/gaps.module.css';
import { headTitles, priceTypeNames } from '../store-constant/table-data';
import { isMqPortrait } from '../store-variables/media-queries';

const bodyTypes = Object.keys(headTitles);
const priceTypes = Object.values(priceTypeNames);

const getSectionCl = computed(() => [
  isMqPortrait.value ? flex.fb100 : flex.fg1,
  flex.flex, flex.aiCenter, gaps.pt, gaps.pb, gaps.pl2, gaps.pr2]);
  
const styleModule = useCssModule();
const getWillBeButtonCl = computed(() => isMqPortrait.value
  ? styleModule.testDesktop : styleModule.testPortrait);
</script>

<template>
  <section :class="getSectionCl">
    <h5 :class="$style.title">Body:</h5>
    <p :class="getWillBeButtonCl">{{ bodyTypes }}</p>
  </section>

  <section :class="getSectionCl">
    <h5 :class="$style.title">Price:</h5>
    <p :class="getWillBeButtonCl">{{ priceTypes }}</p>
  </section>
</template>

<style module>
.title {
  composes: pr from '../css/gaps.module.css';
  composes: normal from '../css/text.module.css';
  composes: grey from '../css/colors.module.css';
}
.test {
  composes: fg1 from '../css/flex.module.css';
  text-align: center;
}
.test-desktop {composes: test; border: 1px solid red;}
.test-portrait {composes: test; border: 1px solid blue;}
</style>

Смысл всего сделанного: все CSS-свойства, задействованные в компонентах, записаны в CSS-модулях, которые разделены по удобным категориям. Если в компоненте CSS-классы какого-нибудь тега, размещенного в <template> — динамические (меняются в зависимости от чего-нибудь, например, computed-переменных), то эти классы можно импортировать в <script setup> и использовать их в новой computed-переменной. В нашем примере классы у тега <section> меняются в зависимости от отображения: если пользователь смотрит страницу на Mobile в Portrait-ориентации, то <section> занимает всю ширину, если нет — сколько получится (см. getSectionCl).

Еще один способ использования CSS-модулей в компонентах — если классы не меняются. Тогда их можно сформировать в <style module>, а в <template> использовать через :class="$style.className", что мы и сделали в заголовках <h5>.

Наконец, классы из <style module> можно задействовать в <script setup> с помощью vue-метода useCssModule(), которым мы воспользовались для передачи классов тегам <p>.

Сделаем коммит “CSS modules”. Cейчас наше творение выглядит так:

А хотелось бы что-нибудь такое:

Желаемое не похоже на сделанное, зато мы разобрались с CSS-модулями, и это пригодится в дальнейшем.

Компонентно-ориентированный подход

Сделаем интерактивные радио-кнопки, по клику которых в таблице будут отображаться те или иные данные. Причем попробуем применить компонентно-ориентированный подход.

Смысл этого подхода (как я его понимаю) — сделать компонент, который может быть полезен в дальнейшем и может быть использован в других проектах. Соответственно, в компоненте не могут быть напрямую использованы (т.е. импортированы) какие-нибудь переменные или константы. Т.е. такой компонент — это «черный ящик», который что-то принимает и что-то выдает, и его API — то, что он принимает и выдает. В vue.js есть для этого всё необходимое — сам Component.vue, а его API — это defineProps()входящие данные и defineEmits() — совершаемые этим компонентом события, и отправляемые в связи с ним данные.

Продумаем API компонента с переключаемыми кнопками. Нужны названия кнопок, нужно знать, какая из кнопок нажата. Кроме того, мы хотим, чтобы кнопки как-то выглядели, т.е. у них должны быть какие-то классы, причем классы могут быть одинаковые для всех, а могут различаться у нажатой, ненажатой кнопки и первой кнопки в ряду. Событие, которое нас интересует - клик по ненажатой кнопке, и отдавать мы можем ее порядковый номер.

Создадим компонент CommonButtonsRadio.vue:

// ./components/CommonButtonsRadio.vue
<script setup>
const props = defineProps({
  buttons: Array,
  indexSelected: Number,
  classes: Object, // { each, first, selected, unselected }; values - Array || String
  // first, selected, unselected are added to 'each' value
});
const emit = defineEmits(['selectButtonIndex']);

const clickToButton = (index) => {
  if (index !== props.indexSelected) {
    emit('selectButtonIndex', index);
  }
};

const seekClass = (index) => {
  const { each, first, selected, unselected } = props.classes;
  const cl = [];
  const addCl = (arrayOrString) => {
    if (typeof arrayOrString === 'string') {
      cl.push(arrayOrString);
    } else {
      cl.push(...arrayOrString);
    }
  };
  if (each) addCl(each);
  if (index === props.indexSelected) {
    if (selected) addCl(selected);
  } else if (unselected) addCl(unselected);
  if (first && !index) addCl(first);
  return cl;
};
</script>

<template>
  <p
    v-for="(name, i) in props.buttons"
    :key="`radio-button-${i}`"
    @click="clickToButton(i)"
    :class="seekClass(i)"
    v-html="name"
  ></p>
</template>

В <script setup> мы сначала определели API (через defineProps и defineEmits), затем идет функция clickToButton(), реагирующая на событие и отдающая индекс нажатой кнопки, затем - функция seekClass(), которая определяет, является ли кнопка нажатой/не нажатой, и первая ли в ряду, и в зависимости от этого отдает соответствующие классы из API. Все, наш компонент готов для использования не только в этом, но и в других проектах, можно его загрузить на Гитхаб и использовать на здоровье.

Теперь задействуем созданный компонент в нашем проекте. Сначала займемся стилями. Изменения / новые файлы:

// ./css/colors.module.css
:root {
  --color-blue: #2d96cd;
  --color-grey: #8c8c8c;
  --color-white: #fff;
  --color-major: var(--color-blue);
}
.back-grey {background-color: var(--color-grey);}
.back-color {background-color: var(--color-major);}

.bb-grey {border-bottom: 1px solid var(--color-grey);}
.bl-grey {border-left: 1px solid var(--color-grey);}
.br-grey {border-right: 1px solid var(--color-grey);}
.bt-grey {border-top: 1px solid var(--color-grey);}

.bb {border-bottom: 1px solid var(--color-major);}
.bl {border-left: 1px solid var(--color-major);}
.br {border-right: 1px solid var(--color-major);}
.bt {border-top: 1px solid var(--color-major);}

.grey {color: var(--color-grey);}
.color {color: var(--color-major);}
.white {color: var(--color-white);}

// ./css/cursor.module.css
.pointer {cursor: pointer;}

// add into ./css/gaps.module.css
.padding {padding: var(--gap);}

// ./css/shadow.module.css
.shadow {box-shadow: 0 0 0.15em 0.1em rgba(0,0,0,0.1);}

// add into ./css/text.module.css
.center {text-align: center;}

Создадим классы, общие для всех кнопок:

// ./css/buttons-radio.module.css
.each {
  composes: fg1 from './flex.module.css';
  composes: padding from './gaps.module.css';
  composes: center from './text.module.css';
}
.selected {
  composes: white from './colors.module.css';
}
.unselected {
  composes: pointer from './cursor.module.css';
  composes: shadow from './shadow.module.css';
}

А после этого — для кнопок в двух цветовых гаммах:

// ./css/buttons-radio-color.module.css
.each {
  composes: each from './buttons-radio.module.css';
  composes: bt br bb from './colors.module.css';
}
.first {
  composes: bl from './colors.module.css';
}
.selected {
  composes: selected from './buttons-radio.module.css';
  composes: back-color from './colors.module.css';
}
.unselected {
  composes: unselected from './buttons-radio.module.css';
  composes: color from './colors.module.css';
}

// ./css/buttons-radio-grey.module.css
.each {
  composes: each from './buttons-radio.module.css';
  composes: bt-grey br-grey bb-grey from './colors.module.css';
}
.first {
  composes: bl-grey from './colors.module.css';
}
.selected {
  composes: selected from './buttons-radio.module.css';
  composes: back-grey from './colors.module.css';
}
.unselected {
  composes: unselected from './buttons-radio.module.css';
  composes: grey from './colors.module.css';
}

Переходим к js:

// ./store-variables/body-type.js
import { computed, ref } from 'vue';
import classes from '../css/buttons-radio-color.module.css';
import { headTitles } from '../store-constant/table-data';

const buttons = Object.keys(headTitles);
const refBodyTypeIndex = ref(0);
const getBodyTypeIndex = computed(() => refBodyTypeIndex.value);
const setBodyTypeIndex = (index) => {
  refBodyTypeIndex.value = index;
};
const propsButtonsBodyType = computed(() => ({
  buttons,
  indexSelected: getBodyTypeIndex.value,
  classes,
}));

export { getBodyTypeIndex, propsButtonsBodyType, setBodyTypeIndex };

// ./store-variables/price-type.js
import { computed, ref } from 'vue';
import classes from '../css/buttons-radio-grey.module.css';
import { priceTypeNames } from '../store-constant/table-data';
const buttons = Object.values(priceTypeNames);
const refPriceTypeIndex = ref(0);
const getPriceTypeIndex = computed(() => refPriceTypeIndex.value);
const setPriceTypeIndex = (index) => {
  refPriceTypeIndex.value = index;
};
const propsButtonsPriceType = computed(() => ({
  buttons,
  indexSelected: getPriceTypeIndex.value,
  classes,
}));
export { getPriceTypeIndex, propsButtonsPriceType, setPriceTypeIndex };

Стили, определенные в классах в модулях buttons-radio-color.module.css и buttons-radio-grey.module.css, мы сразу грузим как classes соответственно в body-type.js и price-type.js, в props. Теперь осталось загрузить последние в созданный ранее компонент ButtonsForTable.vue и подключить там универсальный “черный ящик” CommonButtonsRadio.vue:

// ./components/ButtonsForTable.vue
<script setup>
import { computed } from 'vue';
import flex from '../css/flex.module.css';
import gaps from '../css/gaps.module.css';
import {
  propsButtonsBodyType,
  setBodyTypeIndex,
} from '../store-variables/body-type';
import { isMqPortrait } from '../store-variables/media-queries';
import {
  propsButtonsPriceType,
  setPriceTypeIndex,
} from '../store-variables/price-type';
import CommonButtonsRadio from './CommonButtonsRadio.vue';

const getSectionCl = computed(() => [
  isMqPortrait.value ? flex.fb100 : flex.fg1,
  flex.flex, flex.aiCenter, gaps.pt, gaps.pb, gaps.pl2, gaps.pr2]);
</script>

<template>
  <section :class="getSectionCl">
    <h5 :class="$style.title">Body:</h5>
    <CommonButtonsRadio v-bind="propsButtonsBodyType"
      @select-button-index="setBodyTypeIndex" />
  </section>

  <section :class="getSectionCl">
    <h5 :class="$style.title">Price:</h5>
    <CommonButtonsRadio v-bind="propsButtonsPriceType"
      @select-button-index="setPriceTypeIndex" />
  </section>
</template>

<style module>
.title {
  composes: pr from '../css/gaps.module.css';
  composes: normal from '../css/text.module.css';
  composes: grey from '../css/colors.module.css';
}
</style>

Пробуем запустить и видим, что получилось:

Все работает, нажимается и изменяется. Адаптивной таблицы, правда, пока нет, но мы уже разбираемся в CSS-модулях, знаем про компонентно-ориентированный подход и умеем создавать компоненты, который можно использовать в дальнейшем. И все эти скиллы помогут нам в работе с таблицей. К ней, наконец, и приступим.

Все изменения в проекте до этого момента — в коммите “CommonButtonsRadio”

API компонента адаптивной таблицы

Таблицу мы также будем делать “черным ящиком”, чтобы можно было ей потом пользоваться. Начнем с главного — API. В рамках данной статьи наш компонент будет только принимать данные, и ничего не отдавать.

Подумаем над шапкой. Шапка будет состоять из двух частей — шапки заголовков строк (headTitles) и шапки остальных столбцов (headCols). Если у нас достаточно ширины для классического отображения таблицы — эти шапки идут слева направо, если нет — сверху вниз. Затем у нас идут заголовки строк (titles), ну и сами ячейки с данными для сравнения (cells).

Теперь про CSS-классы. Кроме классов, которые могут быть у конкретной ячейки (clAdd, добавляется ко всем вычисленным к ячейке классам), могут быть классы у:

  • всех ячеек (eachCell)

  • шапки таблицы (head)

  • заголовков строк (titles)

  • остальных ячеек (cells)

head, titles и cells добавляются к eachCell, если они есть.Плюс к этому, они являются некими категориями, которые могу подразделяться на:

  • любая ячейка в своей категории (each)

  • первая/последняя ячейка в строке (rowFirst/rowLast)

  • первая/последняя ячейка в столбце (colFirst, colLast)

Классы из rowFirst, rowLast, colFirst, colLast добавляются к each, если они есть.

Таблица, в отличие от радио-кнопок — вполне себе самостоятельный блок, поэтому логично оборачивающий ее тег поместить внутрь компонента. Поэтому в classes входящих данных таблицы добавим ключ outer.

Еще одно отличие от предыдущего компонента с кнопками — кода будет больше. Поэтому для компонента таблицы создадим папку “CommonTableAdaptive” и уже в ней — TableTemplate.vue:

// ./components/TableTemplate.vue
<script setup>
const props = defineProps({
  headTitles: Array,
  headCols: Array,
  titles: Array,
  cells: Array,
  classes: Object,
  /* classes: {
    outer: [String, Array],
    eachCell: [String, Array],
    head: { each, colFirst, colLast, rowFirst, rowLast }
    titles: { each, colFirst, colLast, rowFirst, rowLast }
    cells: { each, colFirst, colLast, rowFirst, rowLast }
  } */
});
</script>

Займемся props для нашего компонента с таблицами. Заведем для этого отдельный файл table-props.js и используем данные для таблицы, подготовленные ранее в “./store-constant/table-data”:

// ./store-variables/table-props.js
import { computed } from 'vue';
import { cells, headCols, headTitles, titles } from '../store-constant/table-data';
import { getBodyTypeIndex } from '../store-variables/body-type';
import { getPriceTypeIndex } from '../store-variables/price-type';

const cellswithCl = Object.fromEntries(Object.entries(cells)
  .map(([body, obj]) => [
    body,
    Object.fromEntries(Object.entries(obj)
      .map(([priceType, rows]) => [
        priceType,
        rows.map((row) => row.map((price) => price === '-'
          ? { text: '-', clAdd: [colors.grey] }
          : { text: `£&nbsp;${price}`, clAdd: [text.bold] })),
      ])),
  ])
);

const getTableProps = computed(() => ({
  headTitles: Object.values(headTitles)[getBodyTypeIndex.value],
  headCols,
  titles: Object.values(titles)[getBodyTypeIndex.value],
  cells: Object.values(Object.values(cellswithCl)[getBodyTypeIndex.value])[
    getPriceTypeIndex.value],
}));

И в этом же файле займемся стилями. Сформируем классы для тега, оборачивающего таблицу:

// добавить в ./store-variables/table-props
import flex from '../css/flex.module.css';
import gaps from '../css/gaps.module.css';
import text from '../css/text.module.css';
import { isMqMobile } from '../store-variables/media-queries';

const getClOuter = computed(() => {
  const cl = [flex.fb100, gaps.mt2, text.lineHeightTable, text.center];
  if (!isMqMobile.value) cl.push(gaps.pl2, gaps.pr2);
  return cl;
});

// добавить в ./css/gaps.module.css
.mt2 {margin-top: var(--gap-twice);}
// добавить в ./css/text.module.css
.line-height-table {line-height: 1.2;}

Если у нас desktop-отображение, то у границы будут паддинги справа и слева, если mobile — таблица во всю ширину экрана.

Теперь стили ячеек:

// добавить в ./store-variables/table-props
import colors from '../css/colors.module.css';

const getClasses = computed(() => {
  const classesCells = { each: [colors.btGrey, colors.blGrey] };
  if (isMqMobile.value) {
    classesCells.colFirst = [colors.blTransparent];
  } else {
    classesCells.colLast = [colors.brGrey];
    classesCells.rowLast = [colors.bbGrey];
  }
  const classes = {
    outer: getClOuter.value,
    eachCell: [flex.flex, flex.aiCenter, flex.jcCenter, gaps.padding],
    head: {
      each: [text.bold, colors.backColor, colors.white,
        colors.btWhite, colors.blWhite],
      colFirst: [colors.blTransparent],
    },
    cells: classesCells,
    titles: Object.fromEntries(Object.entries(classesCells)),
  };
  classes.titles.each = [...classes.titles.each, colors.backGreyLight];
  return classes;
});

// ./css/colors.module.css
// добавить внутрь :root {}
--color-grey-light: #e5e5e5;
// добавить в файл
.back-grey-light {background-color: var(--color-grey-light);}
.bl-white {border-left: 1px solid var(--color-white);}
.bt-white {border-top: 1px solid var(--color-white);}
.bl-transparent {border-left: 1px solid transparent;}

// добавить в ./css/flex.module.css
.jc-center {justify-content: center;}
// добавить в ./css/text.module.css
.bold {font-weight: bold;}

У всех ячеек (eachCell) центрирование содержимого с помощью flex и отступы. Фон шапки (head) — синий, цвет букв — белый, у каждой ячейки сверху и справа (за исключением первых в ряду) белый бордюр. Остальные ячейки — серые границы сверху, слева (если mobile и первая в ряду, то слева границы нет), а также снизу и справа (только если desktop), если ячейка последняя в колонке и ряду соответственно. У ячеек, относящихся к названиям строк таблицы — светло-серый фон.

Важное примечание, относящееся к CSS-модулям. Во Vue и Vite так устроено (а может это глобально так работает), что если у какого-то тега подключены два класса, влияющие на одно и то же свойство, то срабатывает не последний класс в class="", а то свойство, которое загрузилось последним в модулях. Поясню на примере: в “./css/colors.module.css” сначала идет .bt-white {border-top: 1px solid var(--color-white);}, а потом (ниже) .bl-transparent {border-left: 1px solid transparent;}. Какой-бы ни был порядок этих классов у любого тега, например <p :class="[colors.blWhite, colors.blTransparent]"> или <p :class="[colors.blTransparent, colors.blWhite]">, у этого тега будет прозрачная левая граница, потому что класс .bl-transparent объявлен позднее (ниже) в файле, чем .bt-white.

Включим созданные getClasses в данные, передаваемые компоненту, и экспортируем getTableProps:

// ./store-variables/table-props.js
// добавить в возвращаемый объект getTableProps = computed(() => ({})
classes: getClasses.value,
// добавить в конец файла
export { getTableProps };

И подключим переменную и новый компонент в App.vue

// ./App.vue
// добавить в <script setup>
import { getTableProps } from './store-variables/table-props';
import CommonTableAdaptive from './components/CommonTableAdaptive/TableTemplate.vue';
// добавить в конец <template>
<CommonTableAdaptive v-bind="getTableProps" />

Коммит “CommonTableAdaptive: props, API” Начало положено!

Классический вид таблицы

Отобразим таблицу в классическом представлении. Также будем ориентироваться на семантическую верстку, и поможет нам в этом CSS Grid. Концепция <template> такая:

<section>
  <h6>Ячейка шапки 1</h6><h6>Ячейка шапки N</h6>
  <p>Ячейка 1</p><p>Ячейка 1 N</p>
<section>

Классические для таблицы теги <table>, <tr>, <th>, <td> я не стал использовать хотя бы по двум причинам:

  • бóльшая вложенность, больше тегов

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

Оборачивающий таблицу <section>

Начнем с самого простого — <section>. Для того, чтобы ячейки в таблице отображались как нам надо, оборачивающему тегу нужно задать два свойства: display: grid; grid-template-columns: repeat(N, auto);, где N — количество ячеек таблицы. Поскольку этих ячеек может быть в ряду сколько угодно, и они могут динамически меняться, классов на них не напасешься, поэтому лучше свойство grid-template-columns задать инлайн-стилем. А раз уж у нас появляются инлайн-стили у этого элемента, и наш компонент - “черный ящик”, то и “display: grid;” мы включим туда же.

Итак, нам нужно общее количество колонок таблицы. Оно складывается из количества колонок props.titles и props.cells. Для расчета можно взять первые строки этих массивов. Если бы у нас каждая ячейка занимала одну колонку, достаточно было бы знать длины этих первых строк. Но, поскольку в строках таблицы может встретится ячейка вида { text: 'ячейка', cols: 3 } с любым числом в cols, то длина строки titles или cells может не соответствовать кол-ву колонок в ней. Поэтому вместо длины массива используется функция countCollsNum, складывающая колонки через .reduce() с учетом cols.

Посчитаем их:

// добавить в <script setup> в ./components/CommonTableAdaptive/TableTemplate.vue
import { computed, watch } from 'vue';
import { getColsNumber, setColsNumber } from './computed-cols-number';

setColsNumber([props.titles[0], props.cells[0]]);
watch(() => [props.titles[0], props.cells[0]], setColsNumber);

// ./components/CommonTableAdaptive/computed-cols-number.js
import { computed, ref } from 'vue';

const refColsNumber = ref(0);
const getColsNumber = computed(() => refColsNumber.value);

const countCollsNum = (row) => row.reduce((sum, { cols }) => sum + (cols || 1), 0);

const setColsNumber = ([titlesFirstRow, cellsFirstRow]) => {
  refColsNumber.value = countCollsNum(cellsFirstRow) + countCollsNum(titlesFirstRow);
};

export { getColsNumber, setColsNumber };

Количество колонок мы высчитали, пора задать тегу <section> стили и классы:

// добавить в <script setup> в ./components/CommonTableAdaptive/TableTemplate.vue
const getSectionSt = computed(() => `display: grid;
  grid-template-columns: repeat(${getColsNumber.value}, auto);`);

const getSectionCl = computed(() =>   props.classes && 'outer' in props.classes
  ? props.classes.outer : '');

// добавить в конец файла
<template>
  <section :class="getSectionCl" :style="getSectionSt">
    There will be a table here.
  </section>
</template>

Теперь, если мы запустим npm run dev в командной строке, и понажимаем на кнопки “Saloon / Hatchback” / “Estate”, в консоли браузера можно увидеть, как динамически изменяется свойство grid-template-columns у <section>. Значит, работает как надо :)

Шапка таблицы

headTitles, headCols, titles и cells в props — массивы из строк таблицы, каждая строка — массив из ячеек. Ячейка может быть либо строкой, либо числом, либо объектом. Нужна функция, преобразовывающая все ячейки в объекты. Напишем ее в отдельном модуле:

// ./components/CommonTableAdaptive/transform-cells-to-object.js
const transformCellsToObject = (rows) => rows
  .map((row) => row
    .map((cell) => (typeof cell === 'object' ? { ...cell } : { text: cell })));

export { transformCellsToObject };

Шапка таблицы состоит из двух частей — headTitles и headCols. Займемся первой:

// ./components/CommonTableAdaptive/seek-head-titles.js
import { transformCellsToObject } from './transform-cells-to-object';

const seekHeadTitles = (propsHeadTitles) => {
  let rows;
  if (propsHeadTitles) rows = transformCellsToObject(propsHeadTitles);
  return rows;
};

export { seekHeadTitles };

// ./components/CommonTableAdaptive/TableTemplate.vue
// добавить в <script setup>
import { seekHeadTitles } from './seek-head-titles';
// перед закрытием </script setup>
const getHeadTitles = computed(() => seekHeadTitles(
  props.headTitles,
  props.headCols,
));
console.log(getHeadTitles.value);

Теперь у нас каждая ячейка — объект.  В классическом представлении шапка заголовков строк headTitles размещена слева, шапка остальных ячеек — справа. Но у этих частей шапки может быть разное число строк, и у нас как раз такой случай. Решим эту задачу:

// ./components/CommonTableAdaptive/add-head-rows.js
const addRowsToHead = (arrayOfRows, differenceLengths) => {
  const firstRow = arrayOfRows.shift();
  arrayOfRows.unshift(firstRow.map((cell) => ({
      ...cell,
      rows: 'rows' in cell ? cell.rows + differenceLengths: differenceLengths + 1,
  })));
  return arrayOfRows;
};

export { addRowsToHead };

Функция addRowsToHead() принимает массив из строк и разницу длин строк, и преобразует ячейки первой строки массива — там появляется ключ rows, если его не было, иначе значение этого ключа увеличивается на разницу строк headTitles и headCols.

Добавим новую функцию в seekHeadTitles:

// ./components/CommonTableAdaptive/seek-head-titles.js
import { addRowsToHead } from './add-head-rows';
import { transformCellsToObject } from './transform-cells-to-object';

const seekHeadTitles = (propsHeadTitles, propsHeadCols) => {
  let rows;
  if (propsHeadTitles) rows = transformCellsToObject(propsHeadTitles);
  if (rows && propsHeadCols && propsHeadCols.length > propsHeadTitles.length) {
    rows = addRowsToHead(rows, propsHeadCols.length - propsHeadTitles.length);
  }
  return rows;
};

export { seekHeadTitles };

В консоли браузера при запущенном dev-режиме мы видим, что у всех ячеек-объектов появилось rows: 2. Отлично, думаем дальше.

Теперь нам нужно массив из строк, которые массивы из ячеек, преобразовать в массив из ячеек. Но при этом нужно дать ячейкам знания, являются ли они первыми/последними в ряду и колонке. Кроме того, ячейке нужно передать тип блока, к которому она относится, чтобы впоследствии подключить соответствующие классы. И эта функция также пригодится для шапки колонок таблицы. Напишем ее:

// ./components/CommonTableAdaptive/transform-rows-to-cells.js
const transformRowsToCells = (rows, params = {}) => {
  const { cellType, colFirst, colLast, rowFirst } = params;
  const cells = [];
  rows.forEach((row, iRow) => {
    row.forEach((cellExt, iCol) => {
      const cell = { ...cellExt, cellType };
      if (iRow === 0 && rowFirst) cell.rowFirst = true;
      if (iCol === 0 && colFirst) cell.colFirst = true;
      if (iCol === row.length - 1 && colLast) cell.colLast = true;
      cells.push(cell);
    });
  });
  return cells;
};

export { transformRowsToCells };

Функция принимает массив из строк (которые массив из ячеек), и объект, в котором передается тип блока, а также перечислено, нужно ли отмечать первые/последние ячейки в рядах/колонках, и тип блока таблицы (кроме rowLast — последней ячейки в колонке таблицы, потому что шапка таблицы никогда будет последним рядом). Каждая ячейка преобразуется в соответствии с этими параметрами, и на выходе мы получаем массив из этих ячеек.

Подключим написанные функции в seekHeadTitles:

// ./components/CommonTableAdaptive/seek-head-titles.js
import { addRowsToHead } from './add-head-rows';
import { transformCellsToObject } from './transform-cells-to-object';
import { transformRowsToCells } from './transform-rows-to-cells';

const seekHeadTitles = (propsHeadTitles, propsHeadCols) => {
  let rows;
  if (propsHeadTitles) rows = transformCellsToObject(propsHeadTitles);
  if (
    rows &&
    propsHeadCols &&
    propsHeadCols.length > propsHeadTitles.length
  ) {
    rows = addRowsToHead(rows, propsHeadCols.length - propsHeadTitles.length);
  }
  let cells = [];
  if (rows) {
    cells = transformRowsToCells(rows, {
      cellType: 'head',
      colFirst: true,
      rowFirst: true,
    });
  }
  return cells;
};

export { seekHeadTitles };

В консоли браузера при запущенном dev-режиме мы видим, что у всех ячеек-объектов появилось rows: 2, cellType: 'head' и у каких надо ячеек — colFirst: true, rowFirst: true.

Примерно все то же самое делаем для блока headCols — шапки ячеек таблицы:

// ./components/CommonTableAdaptive/seek-head-cols.js
import { addRowsToHead } from './add-head-rows';
import { transformCellsToObject } from './transform-cells-to-object';
import { transformRowsToCells } from './transform-rows-to-cells';

const seekHeadCols = (propsHeadCols, propsHeadTitles) => {
  let rows;
  if (propsHeadCols) rows = transformCellsToObject(propsHeadCols);
  if (rows && propsHeadTitles && propsHeadTitles.length > propsHeadCols.length) {
    rows = addRowsToHead(rows, propsHeadTitles.length - propsHeadCols.length);
  }
  let cells = [];
  if (rows) {
    cells = transformRowsToCells(rows, {
      cellType: 'head',
      colLast: true,
      rowFirst: true,
    });
  }
  return cells;
};

export { seekHeadCols };

Теперь нам нужно преобразовать полученные ячейки-объекты в то, что можно будет передать в <template>. Напишем еще одну функцию transformRowsToTemplate:

// ./components/CommonTableAdaptive/transform-rows-to-template.js
const bringArray = (arrayOrStr) =>
  Array.isArray(arrayOrStr) ? arrayOrStr : [arrayOrStr];

const transformCellToTemplate = (cell, classes) => {
  const { text, cols, rows, cellType, clAdd } = cell;
  
  let st = cell.st || '';
  if (cols) st += `grid-column-end: span ${cols};`;
  if (rows) st += `grid-row-end: span ${rows};`;
  
  const cl = [];
  if ('eachCell' in classes) cl.push(...bringArray(classes.eachCell));
  if (cellType in classes) {
    if ('each' in classes[cellType]) {
      cl.push(...bringArray(classes[cellType].each));
    }
    ['colFirst', 'colLast', 'rowFirst', 'rowLast'].forEach((typ) => {
      if (typ in cell && cell[typ] && typ in classes[cellType]) {
        cl.push(...bringArray(classes[cellType][typ]));
      }
    });
  }
  if (clAdd) cl.push(...bringArray(clAdd));
  return { text, st, cl };
};
const transformRowsToTemplate = (cells, classes) =>
  cells.map((cell) => transformCellToTemplate(cell, classes));

export { transformRowsToTemplate };

Экспортируемая функция transformRowsToTemplate принимает массив из ячеек таблицы и классов из props компонента. Каждая ячейка массива преобразуется функцией transformCellToTemplate.

Функция transformCellToTemplate выдает то, что нужно для <template> компонента:

  • text — содержание ячейки

  • cl — массив css-классов, сформированный в зависимости от того, есть ли классы в самой ячейке, плюс добавляет соотвествующие классы из props.classes (с учетом типа блока таблицы и того, является ли ячейка первой/последней в ряду/колонке)

  • st — строка инлайн-стилей. Если есть ключ со значением rows, то к стилям добавляется grid-row-end: span ${rows}; (ячейка растягивается по вертикали), если cols — grid-column-end: span ${cols}; (растягивается по горизонтали).

Соединим все написанное в TableTemplate.vue:

// ./components/CommonTableAdaptive/TableTemplate.vue
// добавить в <script setup>
import { seekHeadCols } from './seek-head-cols';
import { transformRowsToTemplate } from './transform-rows-to-template';

// удалить console.log(), добавить после функции getHeadTitles
const getHeadcols = computed(() => seekHeadCols(props.headCols, props.headTitles));
const getTableHead = computed(() => transformRowsToTemplate(
  [...getHeadTitles.value, ...getHeadcols.value],
  props.classes
));
  
// изменить <template>
<template>
  <section :class="getSectionCl" :style="getSectionSt">
    <h6 v-for="({ text, cl, st }, i) in getTableHead" :key="`cell-head-${i}`"
      :class="cl" :style="st" v-html="text"></h6>
  </section>
</template>

И смотрим, что получилось:

Заголовок таблицы в классическом виде отображается так, как нужно. Кроме того, он интерактивен — количество столбцов меняется в зависимости от типа кузова
Заголовок таблицы в классическом виде отображается так, как нужно. Кроме того, он интерактивен — количество столбцов меняется в зависимости от типа кузова

Все изменения — в коммите “Classic table: head”

Классическая таблица целиком

Займемся остальными ячейками таблицы. Создадим новый файл для этого:

// ./components/CommonTableAdaptive/seek-table-cells.js
import { transformCellsToObject } from './transform-cells-to-object';

const addTitleCells = (propsTitles, iRow) => {
  const cells = [];
  propsTitles[iRow].forEach((cell, iCol) => {
    cells.push({
      ...cell,
      colFirst: iCol === 0,
      rowLast: iRow === propsTitles.length - 1,
      cellType: 'titles',
    });
  });
  return cells;
};

const addOtherCells = (row, iRow, rowsLength) => {
  const cells = [];
  row.forEach((cell, iCell) => {
    cells.push({
      ...cell,
      colLast: iCell === row.length - 1,
      rowLast: iRow === rowsLength - 1,
      cellType: 'cells',
    });
  });
  return cells;
};

const seekTableCells = (propsTitlesExt, propsCellsExt) => {
  const propsTitles = transformCellsToObject(propsTitlesExt);
  const propsCells = transformCellsToObject(propsCellsExt);
  const cells = [];
  propsCells.forEach((row, iRow) => {
    cells.push(...addTitleCells(propsTitles, iRow));
    cells.push(...addOtherCells(row, iRow, propsCells.length));
  });
  return cells;
};

export { seekTableCells };

В экспортируемой функции seekTableCells мы трансформируем ячейки в массивах props.titles и props.cells в объекты, после этого разбираем props.cells по строкам таблицы, сначала по номеру строки таблицы добавляем в массив ячейки из соответствующего ряда props.titles, и затем - из ряда props.cells. К тем ячейкам, где это необходимо, добавляем ключи colFirst, colLast и rowLast (rowFirst быть не может, потому что выше — шапка таблицы).

Импортируем написанное в TableTemplate.vue:

// ./components/CommonTableAdaptive/TableTemplate.vue:
// добавить в <script setup>
import { seekTableCells } from './seek-table-cells';

const getTableCells = computed(() => transformRowsToTemplate(
  seekTableCells(props.titles, props.cells),
  props.classes,
));

// добавить в <template> перед закрывающим </section>
<p v-for="({ text, st, cl }, i) in getTableCells" :key="`cell-${i}`"
  :class="cl" :style="st" v-html="text"></p>

Смотрим, что получилось… Упс, у нас неправильно отображаются последние ячейки в колонке (↓). А если мы проверим первые ячейки в ряду (←), то там тоже будет ошибка. Все дело в том, что мы определяем их по индексу ячейки в массиве строки и индексу строки в массиве блока. То есть у массива rows с длиной length = 2

rows = [
  [1, 2], // row
  [3, 4], // row
] 

colFirst = true у элементов 1 и 3 (их индекс в массивах row === 0, и rowLast у элементов 3 и 4 (индекс строки, в которой они состоят, равен rows.length - 1). Но в нашем случае может быть так:

rows = [
  [{ text: 1, rows: 2 }, 2], // row
  [4], // row
]

Здесь первая ячейка в первом массиве row занимает два места, “растягивается” на следующую строку. И у нее должно быть rowLast = true, и описанная логика не срабатывает. И следующая ошибка — у элемента 4, у которого colFirst = false, хоть он и первый в своем ряду.

Напишем правильную функцию для определения, является ли ячейка первой в нашей таблице. Вынесем ее в отдельный файл, потому что эта же ошибка актуальна для шапки таблицы, хоть она там и не проявилась:

// ./components/CommonTableAdaptive/check-col-first.js
let prevColFirst = 0;

const checkColFirst = ({ rows }, iCol) => {
  let colFirst;
  if (iCol === 0) {
    if (prevColFirst === 0) {
      colFirst = true;
      if (rows) prevColFirst = rows - 1;
    } else prevColFirst -= 1;
  }
  return colFirst;
};

export { checkColFirst };

Сначала (вне функции) заводим счетчик prevColFirst = 0. Сама функция checkColFirst принимает два аргумета — ячейку-объект (нас интересует только ее ключ rows, поэтому вместо cell мы пишем { rows }, и индекс этой ячейки в строке ряду. Если iCol === 0 (первая ячейка в строке) — смотрим на счетчик prevColFirst. Если он равен нулю, значит исследуемая ячейка — первая в ряду. Если у нее есть ключ rows, значит ячейка “растягивается” вниз, и мы устанавливаем prevColFirst = rows - 1 — триггер для следующих первых ячеек. Если prevColFirst !== 0, значит ячейка первой в ряду не является, даже если она первая в массиве. В таком случае уменьшаем prevColFirst на единицу.

Подключаем написанную функцию:

// ./components/CommonTableAdaptive/seek-table-cells.js
// добавить
import { checkColFirst } from './check-col-first';
// заменить строку 'colFirst: iCol === 0' внутри функции addTitleCells
colFirst: checkColFirst(cell, iCol),

// ./components/CommonTableAdaptive/transform-rows-to-cells.js
// добавить
import { checkColFirst } from './check-col-first';
// заменить строку 'if (iCol === 0 && colFirst) cell.colFirst = true;'
// внутри функции transformRowsToCells
if (colFirst) cell.colFirst = checkColFirst(cell, iCol);

Теперь разберемся с rowLast. Этот ключ нам нужен только для seekTableCells, так что мы можем записать функцию в seek-table-cells.js:

// ./components/CommonTableAdaptive/seek-table-cells.js
const checkRowLast = ({ rows }, iRow, rowsLength) =>
  iRow + (rows || 1) === rowsLength;
  
// заменить строку 'rowLast: iRow === propsTitles.length - 1,' внутри addTitleCells
rowLast: checkRowLast(cell, iRow, propsTitles.length),
// заменить строку 'rowLast: iRow === rowsLength - 1,' внутри addOtherCells
rowLast: checkRowLast(cell, iRow, rowsLength),

Функция checkRowLast принимает аргументы: cell (нас интересует rows), iRow - индекс строки таблицы и rowsLength - кол-во строк в таблице. Последняя ячейка в колонке - если iRow === rowsLength - 1 или если iRows + rows === rowsLength

Проверяем — все замечательно:

Интерактивная таблица в классическом виде отображается так, как и было задумано. Причем с ячейками, объединенными как в рядах (по горизонтали), так и в колонках (по вертикали)
Интерактивная таблица в классическом виде отображается так, как и было задумано. Причем с ячейками, объединенными как в рядах (по горизонтали), так и в колонках (по вертикали)

Коммит “Classic table: all cells”

Делаем таблицу адаптивной

Приступим к адаптации таблицы для небольших экранов (неважно каких устройств, мобильных или нет).

Отслеживание необходимости адаптации

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

// ./components/CommonTableAdaptive/computed-table-wide.js
import { computed, ref } from 'vue';

const refTableWide = ref(true);
const isTableWide = computed(() => refTableWide.value);
const switchTableWide = () => {
  refTableWide.value = !refTableWide.value;
};

export { isTableWide, switchTableWide };

// ./components/CommonTableAdaptive/watch-table-width.js
import { isTableWide, switchTableWide } from './computed-table-wide';

let clientWidthOld;
const watchTableWidthWithArg = (domSection) => {
  const { clientWidth } = document.documentElement;
  if (clientWidth !== clientWidthOld) {
    clientWidthOld = clientWidth
    if (!isTableWide.value) switchTableWide();
    setTimeout(() => {
      if (domSection && domSection.offsetWidth > clientWidth) {
        switchTableWide();
      }
    }, 0);
  }
};

export { watchTableWidthWithArg };

// в <script setup> ./components/CommonTableAdaptive/TableTemplate.vue
// добавить onMounted, ref в строку 'import { computed, watch } from 'vue';'
import { computed, onMounted, ref, watch } from 'vue';
// добавить
import { isTableWide } from './computed-table-wide';
import { watchTableWidthWithArg } from './watch-table-width';
// изменить watch и setColsNumber, было:
setColsNumber([props.titles[0], props.cells[0]]);
watch(() => [props.titles[0], props.cells[0]], setColsNumber);
// стало
setColsNumber([props.titles[0], props.cells[0], isTableWide.value]);
watch(() => [props.titles[0], props.cells[0], isTableWide.value], setColsNumber);
// добавить
const domSection = ref(null);
const watchTableWidth = () => {
  watchTableWidthWithArg(domSection.value);
};
onMounted(watchTableWidth);
window.addEventListener('resize', watchTableWidth);
// добавить ref="domSection" в <template> к тегу <section>
<section :class="getSectionCl" :style="getSectionSt" ref="domSection">

Вычисляемая переменная isTableWide показывает нам, надо ли показывать таблицу в классическом (широком) виде, или ее нужно адаптировать.

Функция watchTableWidthWithArg реагирует на изменение окна браузера. Если новая ширина браузера не равна старой clientWidthOld, происходит сброс таблицы к классическому виду (refTableWide.value = true), и с нулевой задержкой setTimeout (она нужна, чтобы у dom-элемента успели появиться новые данные, в т.ч. интересующая нас ширина) мы сравниваем, выходит ли таблица за границу экрана (ширина таблицы > ширины окна браузера). Если выходит — refTableWide.value = false. Запускаем эту функцию по vue-событию onMounted и по событию ‘resize’ у window.

Важно также добавить отслеживание isTableWide на вотчер watch, по которому запускается расчет колонок таблицы setColsNumber.

Теперь мы знаем, когда нужно показывать широкую таблицу, а когда - узкую. С сеттера расчета колонок и начнем адаптацию таблицы:

// ./components/CommonTableAdaptive/computed-cols-number.js
// изменить функцию setColsNumber
const setColsNumber = ([titlesFirstRow, cellsFirstRow, isTableWide]) => {
  refColsNumber.value = countCollsNum(cellsFirstRow);
  if (isTableWide) refColsNumber.value += countCollsNum(titlesFirstRow);
};

Если таблица широкая, кол-во колонок в ней складывается из колонок props.titles и props.cells, если узкая — считаются только колонки в props.cells

Шапка таблицы

Изменим шапку таблицы. Начнем с шапки заголовков строк:

// ./components/CommonTableAdaptive/seek-head-titles.js
import { addRowsToHead } from './add-head-rows';
import { getColsNumber } from './computed-cols-number';
import { isTableWide } from './computed-table-wide';
import { transformCellsToObject } from './transform-cells-to-object';
import { transformRowsToCells } from './transform-rows-to-cells';

const seekHeadTitles = (propsHeadTitles, propsHeadCols) => {
  let rows;
  if (propsHeadTitles) rows = transformCellsToObject(propsHeadTitles);
  if (
    rows &&
    isTableWide.value &&
    propsHeadCols &&
    propsHeadCols.length > propsHeadTitles.length
  ) {
    rows = addRowsToHead(rows, propsHeadCols.length - propsHeadTitles.length);
  } else if (rows && !isTableWide.value) {
    rows = rows.map((row) => [
      {
        text: row.map(({ text }) => text).join(', '),
        cols: getColsNumber.value,
      },
    ]);
  }
  let cells = [];
  if (rows) {
    cells = transformRowsToCells(rows, {
      cellType: 'head',
      colFirst: true,
      colLast: !isTableWide.value,
      rowFirst: true,
    });
  }
  return cells;
};
export { seekHeadTitles };

В файл мы импортировали две переменных, isTableWide и getColsNumber. В условие, по которому выполняется функция addRowsToHead, мы добавили && isTableWide.value — т.е. это происходит только если таблица широкая. В противном случае (rows && !isTableWide.value) мы трансформируем каждую строку, состоящую из ячеек: складываем там все ячейки в одну, перечисляя текст через запятую, и этой одной ячейке присваиваем cols, равное общему количеству ячеек таблицы (растягиваем получившуюся одну ячейку на всю ширину таблицы). Ну и добавляем ключ colLast к аргументу функции transformRowsToCells со значением !isTableWide.value. Т.е. если таблица узкая, то последняя в строке ячейка становится последней ячейкой таблицы по горизонтали.

Шапка ячеек таблицы меняется не сильно:

// ./components/CommonTableAdaptive/seek-head-cols.js
// добавить
import { isTableWide } from './computed-table-wide';
// изменить условие if () в функции seekHeadCols
// было:
if (
  rows &&
  propsHeadTitles &&
  propsHeadTitles.length > propsHeadCols.length
)
// стало:
if (
  rows &&
  isTableWide.value &&
  propsHeadTitles &&
  propsHeadTitles.length > propsHeadCols.length
)
// в объекте (2-й аршумент функции argument transformRowsToCells)
// добавить
colFirst: !isTableWide.value,
// изменить
rowFirst: isTableWide.value || !propsHeadTitles.length,

Когда таблица широкая, headCols размещается сверху слева в таблице. Для ее ячеек важны rowFirst и colLast. При узкой таблице headCols вверху, только если нет headTitles (поэтому rowFirst: isTableWide.value || !propsHeadTitles.length), и шапка ячеек занимает всю ширину таблицы (поэтому colFirst: !isTableWide.value).

Ячейки таблицы

В классическом представлении ряд (строка) таблицы формируется так: в левой части (сначала) идут ячейки ряда props.titles, в правой части (затем) — ячейки props.cells. Когда таблица узкая, ячейки ряда props.titles складываются в одну и занимает всю ширину таблицы, а ячейки props.cells размещаются под ней:

Ячейки таблицы состоят из двух блоков — titles (серый фон) и cells (белый фон). Изображение показывает, как ячейки одной строки таблицы меняются при ее адаптации
Ячейки таблицы состоят из двух блоков — titles (серый фон) и cells (белый фон). Изображение показывает, как ячейки одной строки таблицы меняются при ее адаптации

Ячейка таблицы может объединяться со следующими ячейками в колонке, если у нее есть ключ rows. При адаптации (!isTableWide) нам такое объединение ячеек не нужно из-за поведения titles. Напишем функцию, которая исключит rows из ячеек, а если этот ключ есть — продублирует ячейки в колонке.

// добавить в ./components/CommonTableAdaptive/seek-table-cells.js
const transformCellsWithoutRows = (rowsExt) => {
  const rows = [];
  rowsExt.forEach((row, iRow) => {
    if (!rows[iRow]) rows[iRow] = [];
    row.forEach((cellExt) => {
      const cell = { ...cellExt };
      let iCol = rows[iRow].findIndex((el) => !el);
      if (iCol < 0) iCol = rows[iRow].length;
      rows[iRow][iCol] = cell;
      if ('rows' in cell) {
        const rowsCell = cell.rows;
        delete cell.rows;
        for (let i = iRow + 1; i < iRow + rowsCell; i += 1) {
          if (!rows[i]) rows[i] = [];
          rows[i][iCol] = cell;
        }
      }
    });
  });
  return rows;
};

В этой функции мы объявляем новый массив из строк rows=[]. Старый массив обходим построчно. Если у нового rows не заведен элемент с массивом строки с индексом iRow — заводим его (if (!rows[iRow]) rows[iRow] = [];). Дальше массив строки row, состоящий из ячеек cell. Если у ячейки-объекиа есть rows, значит она объединяется с ячейками ниже в колонке. Чтобы это убрать, мы продублируем эту ячейку в колонках нового массива rows в цикле for. Поэтому индекс ячейки в строке row не всегда будет совпадать с реальным индексом ячейки (также см. выше, причины написания функции checkColFirst). И этот индекс в строке мы определяем так: смотрим, есть ли в строке с индексом iRow нового массива rows пустой (undefined) элемент. Если да, то iCol (индекс ячейки в строке) будет индексом пустого элемента, если нет — iCol будет равен длине массива.

Изменим функции в seek-table-cells.js:

// ./components/CommonTableAdaptive/seek-table-cells.js
// добавить
import { isTableWide } from './computed-table-wide';
// изменить функцию seekTableCells
const seekTableCells = (propsTitlesExt, propsCellsExt) => {
  let propsTitles = transformCellsToObject(propsTitlesExt);
  let propsCells = transformCellsToObject(propsCellsExt);
  if (!isTableWide.value) {
    propsTitles = transformCellsWithoutRows(propsTitles);
    propsCells = transformCellsWithoutRows(propsCells);
  }
  const cells = [];
  propsCells.forEach((row, iRow) => {
    cells.push(...addTitleCells(propsTitles, iRow));
    cells.push(...addOtherCells(row, iRow, propsCells.length));
  });
  return cells;
};

Если таблица узкая — запускаем transformCellsWithoutRows, чтобы убрать rows в ячейках-объектах и продублировать их столько раз, сколько нужно.

// ./components/CommonTableAdaptive/seek-table-cells.js
// добавить
import { getColsNumber } from './computed-cols-number';
// изменить функцию addTitleCells
const addTitleCells = (propsTitles, iRow) => {
  const cells = [];
  if (isTableWide.value) {
    propsTitles[iRow].forEach((cell, iCol) => {
      cells.push({
        ...cell,
        colFirst: checkColFirst(cell, iCol),
        rowLast: checkRowLast(cell, iRow, propsTitles.length),
        cellType: 'titles',
      });
    });
  } else {
    cells.push({
      text: propsTitles[iRow].map(({ text }) => text).join(', '),
      cols: getColsNumber.value,
      colFirst: true,
      colLast: true,
      cellType: 'titles',
    });
  }
  return cells;
};

Если таблица узкая — объединяем ячейки в ряду propsTitles[iRow] в одну, задаем им cols = getColsNumber.value — растягиваем ячейку на всю ширину, поэтому эта ячейка будет одновременно первой и последней в своей строке (colFirst: true, colLast: true).

// ./components/CommonTableAdaptive/seek-table-cells.js
// добавить в функцию addOtherCells в объект cells.push({})
colFirst: !isTableWide.value && iCell === 0,

У блока cells, если таблица узкая, ячейка — первая в ряду таблицы, если она первая в своей строке.

Смотрим, что получилось:

Всё работает так, как и задумано, таблица адаптируется под маленькие экраны. Таблица интерактивна — данные меняются в зависимости от выбора пользователя. Мало того, на любых экранах таблица компактна — занимает минимум места, что нисколько не мешает восприятию информации, а самое главное — сохраняется главное достоинство таблицы — отображенные в ней данные легко сравнивать друг с другом!

Коммит “Adaptive table”

На этом статью можно было бы и завершить. Но что если колонок в таблице намного больше? Это не очень хорошо для восприятия, и скорее всего данные в такой большой таблице можно сократить, но все-таки?

Если много колонок

В случае, если колонок много в левой части классической таблице — titles, проблем не возникает: в компактном представлении все эти колонки “схлопываются” в одну, которая становится “подшапкой” строки cells с ячейками, где представлены данные для сравнения.

Но что, если этих ячеек настолько много, что они не умещаются в одну строку на маленьком экране? Сделаем адаптацию для бесконечного количества столбцов в cells. Концепция такая: если в строке cells ячейки не помещаются в экран браузера, то одна строка трансформируется в несколько, как на рисунке:

Здесь есть две сложности, которые нужно учесть:

  1. Если в строке есть ячейка, объединяющая несколько колонок, и разделение на подстроки происходит как раз по этим колонкам (“2 columns” на рисунке), то в компактном представлении нужно будет ее дублировать на разделяемые строки

  2. Если после разделения подстроки в последней строке окажется меньше ячеек, чем в предыдущих (“last column” на рисунке), то нужно будет ее растянуть на необходимое количество ячеек

Приступим. Я изменил данные в ‘./store-constant/table-data.js’ и чуть подправил их трансформацию в props в ‘’./store-variables/table-props.js”, чтобы в таблице было много ячеек. Кнопка “Estate” в выборе типа кузова получила название “Many Columns”. При таком выборе “Retail” и “incl. VAT” отображают 7 колонок в cells, отличие в шапке колонок (headCols): в “Retail” первая строка этой шапки “Version” занимает всю часть таблицы (cols: 7), в случае “incl. VAT” первая часть headcols — составная, но в каждой ячейке тоже есть объединение колонок. При выборе “+ Manufacturers” — 13 колонок.

В первую очередь нам нужно определить, на какое количество подстрок будут делиться строки в cells, если они не умещаются по ширине окна браузера. Для этого нам нужно знать общее количество колонок в cells. Мы уже рассчитываем этот показатель в setColsNumber, передавая туда аж три аргумента. Мне это перестало нравится, поэтому я заведу отдельные вычисляемые переменные, отвечающие за количество колонок, и избавлюсь от refColsNumber и setColsNumber:

// ./components/CommonTableAdaptive/computed-first-row-length.js
import { computed, ref } from 'vue';
const countCollsNum = (row) =>
  row.reduce((sum, { cols }) => sum + (cols || 1), 0);
const refPropsTitlesFirstRowLength = ref(0);
const getPropsTitlesFirstRowLength = computed(
  () => refPropsTitlesFirstRowLength.value
);
const setPropsTitlesFirstRowLength = (row0) => {
  refPropsTitlesFirstRowLength.value = countCollsNum(row0);
};
const refPropsCellsFirstRowLength = ref(0);
const getPropsCellsFirstRowLength = computed(
  () => refPropsCellsFirstRowLength.value
);
const setPropsCellsFirstRowLength = (row0) => {
  refPropsCellsFirstRowLength.value = countCollsNum(row0);
};
export {
  getPropsTitlesFirstRowLength,
  setPropsTitlesFirstRowLength,
  getPropsCellsFirstRowLength,
  setPropsCellsFirstRowLength,
};

// ./components/CommonTableAdaptive/TableTemplate.vue
// удалить setColsNumber в строке
// import { getColsNumber, setColsNumber } from './computed-cols-number';
import { getColsNumber } from './computed-cols-number';
// добавить
import {
  setPropsTitlesFirstRowLength,
  setPropsCellsFirstRowLength,
} from './computed-first-row-length';

setPropsTitlesFirstRowLength(props.titles[0]);
watch(() => props.titles[0], setPropsTitlesFirstRowLength);
setPropsCellsFirstRowLength(props.cells[0]);
watch(() => props.cells[0], setPropsCellsFirstRowLength);

// удалить
import { isTableWide } from './computed-table-wide';

setColsNumber([props.titles[0], props.cells[0], isTableWide.value]);
watch(
  () => [props.titles[0], props.cells[0], isTableWide.value],
  setColsNumber
);

// ./components/CommonTableAdaptive/computed-cols-number.js
import { computed } from 'vue';
import {
  getPropsCellsFirstRowLength,
  getPropsTitlesFirstRowLength,
} from './computed-first-row-length';

import { isTableWide } from './computed-table-wide';
const getColsNumber = computed(() => {
  let colsNumber = getPropsCellsFirstRowLength.value;
  if (isTableWide.value) {
    colsNumber += getPropsTitlesFirstRowLength.value;
  }
  return colsNumber;
});

export { getColsNumber };

Заведем вычисляемую переменную RowsSplitter:

// ./components/CommonTableAdaptive/computed-rows-splitter.js
import { computed, ref } from 'vue';

const refRowsSplitter = ref(1);
const getRowsSplitter = computed(() => refRowsSplitter.value);
const setRowsSplitter = (num) => {
  refRowsSplitter.value = num;
};

export { getRowsSplitter, setRowsSplitter };

И изменим функцию watchTableWidthWithArg:

// ./components/CommonTableAdaptive/watch-table-width.js
import { setRowsSplitter } from './computed-rows-splitter';
import { getPropsCellsFirstRowLength } from './computed-first-row-length';
import { isTableWide, switchTableWide } from './computed-table-wide';

let clientWidthOld;
const watchTableWidth = (domSection, changeProps) => {
  const { clientWidth } = document.documentElement;
  if (changeProps || clientWidth !== clientWidthOld) {
    clientWidthOld = clientWidth;
    setRowsSplitter(1);
    if (!isTableWide.value) switchTableWide();
    setTimeout(() => {
      if (domSection && domSection.offsetWidth > clientWidth) {
        switchTableWide();
        setTimeout(() => {
          if (domSection && domSection.offsetWidth > clientWidth) {
            setRowsSplitter(seekRowsSplitter(domSection, clientWidth));
          }
        }, 0);
      }
    }, 0);
  }
};

export { watchTableWidth };

// ./components/CommonTableAdaptive/TableTemplate.vue
// удалить
import { watchTableWidthWithArg } from './watch-table-width';

const watchTableWidth = () => {
  watchTableWidthWithArg(domSection.value);
};
onMounted(watchTableWidth);
window.addEventListener('resize', watchTableWidth);

// добавить
import { watchTableWidth } from './watch-table-width';

onMounted(() => {
  watchTableWidth(domSection.value);
});
window.addEventListener('resize', () => {
  watchTableWidth(domSection.value);
});
watch(
  () => [props.titles[0], props.cells[0]],
  () => {
    watchTableWidth(domSection.value, 'changeProps');
  }
);

Мы переименовали функцию watchTableWidth и добавили к ней аргумент changeProps - чтобы вызывать ее не только при событии window.onresize, но и при изменении кол-ва ячеек в cells или titles. При срабатывании функции мы добавили резет вычисляемой RowsSplitter (setRowsSplitter(1)). Затем, после того, как мы убедились, что нум нужно переключить refTableWide в положение false (т.е. делаем таблицу адаптивной), мы запускаем еще одну нулевую задержку через setTimeout, и проверяем, умещается ли наша уже адаптивная таблица в экран браузера по ширине. Если не умещается - надо разбивать строки sells на подстроки, и нужно высчитать и записать количество этих подстрок (setRowsSplitter(seekRowsSplitter(domSection, clientWidth));).

Займемся функцией seekRowsSplitter. Чтобы начать разбираться с шириной таблицы и шириной ячеек cells, вспомним, что она состоит из оборачивающего тега <section>, ячеек шапки таблицы <h6> и ячеек titles и cells, у которых тег <p>. Причем у ячеек titles, когда таблица уже адаптивная, CSS-свойство “grid-column-end” равно “span N”, где N - общее количество ячеек cells. Напишем функцию, которая определяет, сколько колонок объединяет ячейка, уже являющаяся dom-элементом:

// добавить в ./components/CommonTableAdaptive/watch-table-width.js
const seekCols = (el) => {
  const { gridColumnEnd } = getComputedStyle(el);
  let cols = 1;
  if (gridColumnEnd.startsWith('span ')) {
    cols = Number(gridColumnEnd.slice(5));
  }
  return cols;
};

Теперь нам нужна функция, которая найдет все ширины колонок в cells, которые тоже стали dom-элементами:

// добавить в ./components/CommonTableAdaptive/watch-table-width.js
import { getPropsCellsFirstRowLength } from './computed-first-row-length';

const seekCellsWidths = ({ children }) => {
  const widths = [];
  widths.length = getPropsCellsFirstRowLength.value;
  let leftFirst;
  let iCol = -1;
  Array.from(children)
    .filter(({ tagName }) => tagName === 'P')
    .filter((child) => seekCols(child) !== getPropsCellsFirstRowLength.value)
    .find((child) => {
      const { left, width } = child.getBoundingClientRect();
      if (leftFirst === undefined) leftFirst = left;
      const cols = seekCols(child);
      if (iCol === getPropsCellsFirstRowLength.value - 1) iCol = 0;
      else iCol += cols;
      if (cols === 1) widths[iCol] = width;
      return !widths.includes(undefined);
    });
  return widths.map((w) => Math.round(w));
};

Функция seekCellsWidths принимает набор dom-элементов <section> таблицы. Мы заводим массив widths длиной, равной количеству ячеек cells (это количество знает computed переменная getPropsCellsFirstRowLength). Задача — получить полностью заполненный массив widths, элементами которого будут являться ширины колонок cells.

Преобразуем набор детей dom-элементов <section> в массив, и отфильтруем его: уберем все ячейки шапки таблицы (у этих dom-ячеек tagName !== 'P') и все ячейки titles (у этих ячеек объединенное кол-во столбцов равно кол-ву столбцов cells, т.е. seekCols(child) === getPropsCellsFirstRowLength.value).

Итоговый массив, в котором остались только ячейки из cells, будем проходить до тех пор, пока полностью не заполним массив widths. Добавляем в этот массив только ширину ячейки, которая занимает только одну колонку (функция seekCols выдает 1). Индекс для добавления элемента высчитываем через счетчик iCol, который увеличивается на кол-во колонок, который занимает предыдущая ячейка, и который обнуляется, если его значение вырастает до общей длины колонок за минусом единицы.

Мы знаем ширины всех колонок cells, и теперь можем посчитать, на сколько подстрок нужно разделить каждую строку cells:

// добавить в ./components/CommonTableAdaptive/watch-table-width.js
const seekRowsSplitter = (domSection, clientWidth) => {
  const widths = seekCellsWidths(domSection);
  const matrix = [[]];
  widths.forEach((width) => {
    if (
      matrix[matrix.length - 1].reduce((sum, w) => sum + w, 0) + width >
      clientWidth
    ) {
      matrix.push([]);
    }
    matrix[matrix.length - 1].push(width);
  });
  return matrix.length;
};

В функции seekRowsSplitter заводим массив matrix, который будет состоять из подстрок. В новую (последнюю в массиве) подстроку добавляем ячейку с известной шириной из widths до тех пор, пока эта подстрока умещается в широину экрана, или сумма ширин элементов этой подстроки <= window.clientWidth. Если больше — заводим новую подстроку. В результате мы получаем то, что нужно: длина массива matrix — и есть количество подстрок, на которую нужно разделить каждую строку cells.

Изменим вычисляемую переменную, отвечающую за кол-во колонок таблицы:

// ./components/CommonTableAdaptive/computed-cols-number.js
import { computed } from 'vue';
import {
  getPropsCellsFirstRowLength,
  getPropsTitlesFirstRowLength,
} from './computed-first-row-length';
import { getRowsSplitter } from './computed-rows-splitter';
import { isTableWide } from './computed-table-wide';

const getColsNumber = computed(() => {
  let colsNumber = getPropsCellsFirstRowLength.value;
  if (isTableWide.value) {
    colsNumber += getPropsTitlesFirstRowLength.value;
  } else if (getRowsSplitter.value > 1) {
    colsNumber = Math.ceil(colsNumber / getRowsSplitter.value);
  }
  return colsNumber;
});

export { getColsNumber };

Мы добавили условие (которое выполняется при !isTableWide.value): если нужно строки cells делить на подстроки, то общее количество колонок делится на кол-во этих подстрок с округлением в большую сторону.

Осталось совсем немного — разделить cells на подстроки. Напишем функцию splitRows:

// ./components/CommonTableAdaptive/split-rows.js
import { getColsNumber } from './computed-cols-number';

const splitRows = (rowsOld) => {
  const rowsNew = [];
  let countCol;
  rowsOld.forEach((row) => {
    countCol = 0;
    row.forEach((cellExt, iCol) => {
      if (countCol === 0) {
        rowsNew.push([]);
      }
      const cell = { ...cellExt };
      let { cols } = cell;

      rowsNew[rowsNew.length - 1].push(cell);
      countCol += cols ? cols : 1;
      if (countCol === getColsNumber.value) countCol = 0;
    });
  });
  return rowsNew;
};

export { splitRows };

Пока эта функция достаточно проста: массив rowsNew состоит из строк, длина которых не больше getColsNumber, а в getColsNumber мы уже учитываем количество подстрок getRowsSplitter. Каждую ячейку мы добавляем в конец последней строки в rowsNew. Счетчик countCol с каждой ячейкой растет либо на единицу, если эта ячейка не объединена с другими, либо на cols ячейки-объекта. Как только счетчик вырастает до количества колонок getColsNumber, счетчик обнуляется, и заводится новая строка в rowsNew.

Но есть две проблемы, которые обозначены в начале этой главы, и даже нарисованы (см. в последнем изображении “2 columns” и “last column”. Решим эти проблемы:

// ./components/CommonTableAdaptive/split-rows.js
// добавить перед строкой rowsNew[rowsNew.length - 1].push(cell);
while (cols && cols + countCol > getColsNumber.value) {
  const colsDouble = getColsNumber.value - countCol;
  const cellDouble = { ...cell };
  if (cellDouble.cols === 1) delete cellDouble.cols;
  else cellDouble.cols = colsDouble;
  rowsNew[rowsNew.length - 1].push(cellDouble);
  rowsNew.push([]);
  cols -= colsDouble;
  if (cols === 1) {
    cols = undefined;
    delete cell.cols;
  } else {
    cell.cols = cols;
  }
  countCol = 0;
}

Повторюсь — ячейка в конец последней строки rowsNew добавляется до тех пор, пока счетчик countCol не равен количеству колонок в таблице getColsNumber.value, иначе заводится новая строка в rowsNew и счетчик обнуляется. Если же нам нужно добавить ячейку, у которой есть cols, и cols + countCol > getColsNumber.value — значит эта ячейка, которая занимает несколько колонок, находится на стыке разрыва строк, и ее нужно дублировать, что и происходит в цикле while.

Теперь представим пример: у cell — 7 колонок. На маленьком экране помещается только 4. Каждая строка cell делится на две подстроки, в первой — 4 ячейки, во второй — три. Поскольку у нас табличное представление, надо последнюю ячейку второй строки растянуть на две. Добавим код в функцию, который будет делать подобное:

// ./components/CommonTableAdaptive/split-rows.js
// добавить перед строкой rowsNew[rowsNew.length - 1].push(cell);
if (iCol === row.length - 1) {
  let colsRest = getColsNumber.value - countCol - (cols ? cols : 1);
  if (colsRest) {
    if (cols) cell.cols += colsRest;
    else cell.cols = colsRest + 1;
  }
}

Отлично, теперь функция splitRows разбивает каждую строку cells на подстроки, если строки не умещаются целиком на экране. Изменим код в модуле, который трансформирует ячейки из sells:

// ./components/CommonTableAdaptive/seek-table-cells.js
// добавить
import { getRowsSplitter } from './computed-rows-splitter';
import { splitRows } from './split-rows';

// изменить функцию seekTableCells
const seekTableCells = (propsTitlesExt, propsCellsExt) => {
  let propsTitles = transformCellsToObject(propsTitlesExt);
  let propsCells = transformCellsToObject(propsCellsExt);
  if (!isTableWide.value) {
    propsTitles = transformCellsWithoutRows(propsTitles);
    propsCells = transformCellsWithoutRows(propsCells);
    if (getRowsSplitter.value > 1) {
      propsCells = splitRows(propsCells);
    }
  }
  const cells = [];
  propsCells.forEach((row, iRow) => {
    if (iRow % getRowsSplitter.value === 0) {
      cells.push(...addTitleCells(propsTitles, iRow / getRowsSplitter.value));
    }
    cells.push(...addOtherCells(row, iRow, propsCells.length));
  });
  return cells;
};

Применим написанную функцию и к шапке ячеек:

// ./components/CommonTableAdaptive/seek-head-cols.js
// добавить
import { getColsNumber } from './computed-cols-number';
import { getRowsSplitter } from './computed-rows-splitter';
import { splitRows } from './split-rows';

const transformRowsThroughOne = (rows) => {
  const rowsNew = [];
  for (let i = 0; i < rows.length / 2; i += 1) {
    rowsNew.push(rows[i], rows[rows.length / 2 + i]);
  }
  return rowsNew;
};

// добавить в функцию addRowsToHead перед строкой let cells = [];
if (rows && !isTableWide.value && getRowsSplitter.value > 1) {
  let firstRowAllCols;
  if (rows.length > 1 && rows[0].length === 1) {
    firstRowAllCols = rows.shift();
    firstRowAllCols[0].cols = getColsNumber.value;
  }
  rows = splitRows(rows);
  if (rows.length > 1) rows = transformRowsThroughOne(rows);
  if (firstRowAllCols) rows.unshift(firstRowAllCols);
}

Если у нас адаптивная таблица (!isTableWide.value) и есть разделение строк на подстроки (getRowsSplitter.value > 1), мы смотрим на первую строку шапки. Если она состоит всего из одной ячейки (т.е. растягивается на все колонки в cells), мы ее временно удаляем из шапки, заодно присваивая ей cols, равное количеству колонок с учетом getRowsSplitter. Оставшиеся строки шапки преобразуем также, как и cells до этого. Однако теперь нам нужно раскидать строки через одну, что и делает функция transformRowsThroughOne. Ну и если мы нашли строку с одной ячейкой, прикрепляем ее к шапке обратно.

Все работает как надо:

Коммит “Many Columns”

Таблицы с небольшим количеством ячеек в cells выглядят неплохо, однако чем больше ячеек — тем массивнее шапка, и тем хуже сравнивать данные. Большого количества ячеек следует избегать, но случаи разные бывают. Добавим последний штрих, улучшающий восприятие какой угодно по размеру таблицы.

Движущаяся шапка адаптивной таблицы

В CSS-Grid есть хорошее свойство, которое сделает нашу таблицу более приятной для глаз — “grid-row-start”. Добавим прибавление этого свойства в функцию transformCellToTemplate, которая подготавливает ячейки-объекты для <template>:

// ./components/CommonTableAdaptive/transform-rows-to-template.js
// изменить первую строку внутри функции transformCellToTemplate
// было
const { text, cols, rows, cellType, clAdd } = cell;
// стало
const { text, cols, rows, indent, cellType, clAdd } = cell;

// добавить после строки 'if (rows) st += `grid-row-end: span ${rows};`;'
if (indent) st += `grid-row-start: ${indent + 1};`;

Создадим вычисляемую переменную HeadIndent:

// ./components/CommonTableAdaptive/computed-head-indent.js
import { computed, ref } from 'vue';

const refHeadIndent = ref(0);
const getHeadIndent = computed(() => refHeadIndent.value);

let topMin;
const setHeadIndentFromDom = (domSection, propsCellsColsLength) => {
  if (domSection) {
    const { top, height } = domSection.getBoundingClientRect();
    if (!topMin) topMin = top;
    const topMax = window.innerHeight - height;
    let prc = (topMin - top) / (topMin - topMax);
    if (prc > 1) prc = 1;
    else if (prc < 0) prc = 0;
    prc = Math.round(prc * (propsCellsColsLength - 1));
    refHeadIndent.value = prc;
  }
};

const resetIndent = () => {
  refHeadIndent.value = 0;
  topMin = undefined;
};

export { getHeadIndent, resetIndent, setHeadIndentFromDom };

Функция setHeadIndentFromDom принимает dom-элемент <section> — оборачивающий тег нашей таблицы, и количество строк в cells из props. Когда таблица только загрузилась в dom-дерево, мы можем вычислить высоту <section> относительно окна браузера. Это условный 0%, начало таблицы (topMin). 100% (topMax) — когда нижняя граница таблицы видна в окне браузера. Зная topMin и topMax и текущий top относительно окна браузера при скролле, мы можем рассчитать, на сколько процентов таблица поднялась вверх. А зная количество строк в cells, мы можем определить, к какой строке cells подвинуть шапку таблицы при скролле.

Импортируем созданное:

// ./components/CommonTableAdaptive/watch-table-width.js
// добавить
import { resetIndent, setHeadIndentFromDom } from './computed-head-indent';
// изменить функцию watchTableWidth
const watchTableWidth = (domSection, propsCellsColsLength, changeProps) => {
  const { clientWidth } = document.documentElement;
  if (changeProps || clientWidth !== clientWidthOld) {
    clientWidthOld = clientWidth;
    setRowsSplitter(1);
    resetIndent();
    if (!isTableWide.value) switchTableWide();
    setTimeout(() => {
      if (domSection && domSection.offsetWidth > clientWidth) {
        switchTableWide();
        setTimeout(() => {
          if (domSection && domSection.offsetWidth > clientWidth) {
            setRowsSplitter(seekRowsSplitter(domSection, clientWidth));
          }
          setHeadIndentFromDom(domSection, propsCellsColsLength);
        }, 0);
      }
    }, 0);
  }
};

// ./components/CommonTableAdaptive/TableTemplate.vue
// добавить
import { setHeadIndentFromDom } from './computed-head-indent';
// трижды добавить 2-й аргумент props.cells.length в функцию watchTableWidth
onMounted(() => {
  watchTableWidth(domSection.value, props.cells.length);
});
window.addEventListener('resize', () => {
  watchTableWidth(domSection.value, props.cells.length);
});
watch(
  () => [props.titles[0], props.cells[0]],
  () => {
    watchTableWidth(domSection.value, props.cells.length, 'changeProps');
  }
);
// добавить перед закрывающим тегом </script>
window.addEventListener('scroll', () => {
  setHeadIndentFromDom(domSection.value, props.cells.length);
});

Создадим функцию addHeadIndent:

// ./components/CommonTableAdaptive/add-head-indent.js
import { getHeadIndent } from './computed-head-indent';
import { getRowsSplitter } from './computed-rows-splitter';

const addHeadIndent = (rows, titlesLength, throughOne) => {
  let countRow = 0;
  return rows.map((row, iRow) => {
    const rowNew = row.map((cell) => ({
      ...cell,
      indent:
        countRow +
        1 +
        titlesLength +
        getHeadIndent.value * (1 + getRowsSplitter.value),
    }));
    countRow += throughOne && iRow >= 1 && iRow % 2 ? 2 : 1;
    return rowNew;
  });
};

export { addHeadIndent };

Эта функция будет добавлять к ячейкам шапки ключ indent с необходимым значением. Проследив на видео ниже за поведением шапки таблицы,станет понятно, что эта функция делает. Импортируем ее в функции, которые работают с ячейками шапки:

// ./components/CommonTableAdaptive/seek-head-titles.js
// добавить
import { addHeadIndent } from './add-head-indent';
import { getHeadIndent } from './computed-head-indent';
// добавить перед закрывающим '}'
// внутри 'else if (rows && !isTableWide.value)'
if (getHeadIndent.value > 0) {
  rows = addHeadIndent(rows, -1);
}

// ./components/CommonTableAdaptive/seek-head-cols.js
// добавить
import { addHeadIndent } from './add-head-indent';
// внутри функции seekHeadCols:
// добавить после строки 'let rows;'
let throughOne;
// изменить строку, было:
if (rows.length > 1) rows = transformRowsThroughOne(rows);
// стало
if (rows.length > 1) {
  rows = transformRowsThroughOne(rows);
  throughOne = true;
}
// добавить перед строкой 'let cells = [];'
if (rows && !isTableWide.value) {
  rows = addHeadIndent(rows, propsHeadTitles.length, throughOne);
}

Коммит “Moving Table Header”

Смотреть, что получилось, лучше в движении:

или на веб-странице.

Теги:
Хабы:
Всего голосов 9: ↑9 и ↓0+9
Комментарии7

Публикации

Истории

Работа

Веб дизайнер
25 вакансий

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань