Таблица — удобный и один из самых эффективных способов подачи ТЕКСТОВОЙ информации: на минимуме пространства размещено максимум данных. И что не менее важно — эти данные доступны не только для восприятия, но и для анализа (СРАВНЕНИЯ). Основная сложность таблиц при верстке — их адаптивность для устройств с небольшими экранами (мобильных девайсов). Можно ли сделать так, чтобы даже на экране с размерами в несколько сантиметров таблицы могли быть удобными для восприятия? В статье будет предложен способ адаптации таблиц к разным экранам разных устройств. Кроме того, этот способ будет реализован на практике с применением Vue.js 3, CSS-модулей и компонентно-ориентированного подхода. Структура таблицы Цветовая дифференциация Проблема с Mobile First Что делать с таблицами на маленьких экранах? Back to the Future Forward to the Past, или что будем улучшать Mobile First или Desktop First? Без разницы! Подготовка к работе, Vue.js Структура проекта и нейминг CSS-модули Компонентно-ориентированный подход API компонента адаптивной таблицы Классический вид таблицы Делаем таблицу адаптивной Если много колонок Движущаяся шапка адаптивной таблицы Таблица состоит из столбцов и строк. Первая, самая верхняя строка — шапка таблицы. Т.е. ЗАГОЛОВКИ столбцов. Важно отметить, что первый столбец, как правило, играет не менее важную роль, т.к. в его ячейках — информация, которую можно обозначить как ЗАГОЛОВКИ строк. Таблица может быть усложнена (что, как ни парадоксально, не усложняет, а наоборот, упрощает ее восприятие): шапка таблицы может состоять не из одной, а из нескольких строк, причем в каких-то столбцах эти строки могут быть объединены, а в каких-то — могут быть подстолбцы кроме заголовков строк в первом столбце, во втором могут быть подзаголовки этих строк для улучшения восприятия ячейки с одинаковыми данными могут быть объединены как по горизонтали, так и по вертикали Данные, обозначенные в ячейках, легче воспринимаются, если эти ячейки разделены границами. Не менее важный аспект — дифференциация разных элементов таблицы по цветам. Как правило, чаще всего выделяют другим цветовым фоном шапку таблицы, но не менее важно, особенно при адаптации таблицы для маленьких экранов (об этом ниже), выделять фоном заголовки строк. При наличии границ восприятие информации также улучшает центрирование в ячейках. И еще один приятный для глаз момент — наличие отступов, по крайней мере вертикальных (оптимальный вариант, на мой взгляд — от 0.25em до 0.5em). Да, и при этом (наличии отступов) межстрочный интервал в ячейках не должен быть таким же большим, как у обычных параграфов. Основная проблема, почему таблицы исчезли с сайтов — очень небольшая ширина мобильных экранов, особенно в portrait-ориентации. Часто заголовки строк состоят из нескольких слов, и поэтому достаточно длинны, и все что остается — один-два столбца с данными, которые не умещаются в ширину экрана при нормальном (читаемом) размере шрифта. Именно поэтому принцип “Mobile First”, популярный в адаптивном дизайне, сводит использование таблиц к нулю. Вместо них получил распространение “карточный подход”, когда все данные, которые можно было бы уместить в строку, представлены в виде списка. Недостатков такого подхода множество. Самые главные из них: карточки — это “мусор” в дизайне: на странице — множество повторяющихся элементов, которых могло бы и не быть (правая часть списков под подзаголовками Subtitles, которые мешают восприятию) карточки занимают много места: на экране мобильного устройства видно очень немного карточек без скролла — две, максимум три; чтобы ознакомиться со всей информацией, нужно пролистывать страницу вверх или вниз самое главное — теряется уникальное преимущество таблиц — анализ, СРАВНЕНИЕ данных, которые были до этого представлены в ячейках компактно, рядом друг с другом Есть один подход к таблицам, который мне нравится. Скорее всего кто-нибудь это придумал давным-давно, и я “изобрел велосипед”, просто не видел такого воплощения адаптивной верстки таблиц. Смысл подхода заключается в том, что на широких экранах пользователь видит классическую таблицу, а на узких — заголовки (и подзаголовки) строк трансформируются в эдакие “подшапки” таблицы. Довольно теории и придуманных картинок, перейдем к реальным примерам. И, если вдруг их можно улучшить — попробуем сделать это, заодно потренируясь работать в моем любимом vue.js. Благодаря Генри Форду и его первой конвейерной линии, созданной в 1913 году, в производстве практически всех товаров появился конвейер. Подход Тойоты к организации процесса — канбан (1959 год) — стал основой для agile-методологии, которая очень популярна в IT-индустрии. Автопроизводители всегда стремились быть первооткрывателями во многих сферах (еще один показательный пример забыл — product placement с «Бондиадой» в кино). Ну или хотя бы брали на вооружение передовые технологии. Предлагаю в качестве примера рассмотреть то, что нам предлагают автомобильные бренды в интернете (примечание — с российским автобизнесом сейчас, мягко говоря... “все  не так однозначно”, поэтому примеры я искал на сайтах .uk, потому что  там английский язык)   Представим, что мы хотим купить новый автомобиль. Мы определились с маркой, моделью, и даже примерно представляем чем отличаются версии, в которых эта модель производится. Мы понимаем, двигатель какой мощности и объема нас устраивает, и какого типа коробку передач мы хотим. Наше желание — ПРИЦЕНИТЬСЯ, т.е. понять разброс цен в зависимости от перечисленных выше условий. У всех автопроизводителей есть конфигураторы, но, чтобы понять, насколько отличается цена, в этих конфигураторах нужно совершать множество действий, переходить по шагам/этапам и пр. Чтобы прицениться, определить порядок цен, есть один действенный инструмент — прайс-лист. Попробуем найти его на сайтах и посмотреть, можно ли его как-то улучшить. Начать я решил с Volkswagen Passat, потому что когда-то ездил на такой машине. Но на английском оф. сайте этого бренда я вообще не увидел прайс-листов! Ок, новый Passat проектирует дизайн-команда Skoda, рассмотрим «младшего брата» из Чехии — Superb. Для сравнения рассмотрим американскую альтернативу — Ford Mondeo, и японскую — Toyota Corolla. Прайс-листы для этих моделей на английских сайтах нашлись, но! — все они почему-то представлены в формате pdf.  Я не представляю, как эти 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’]” И немного переформатируем таблицу: У мобильных телефонов — разные размеры экранов. Планшеты — тоже  мобильные устройства, и с их учетом количество вариантов возможных  адаптаций стремится к бесконечности. А если учитывать  и landscape-ориентацию мобильных девайсов, запутаться можно  окончательно. Поэтому можно поступить проще — вообще не обращать на это  внимание 🙂 Всё, что нас интересует — ширина таблицы. Если все  умещается — нет проблем, если не умещается — значит заголовки строк  выносим в отдельные строки. Я буду использовать последнюю версию Vue.js (3 на момент написания статьи) и поэтапно покажу и расскажу, что буду делать. Весь код и коммиты, описанные здесь, можно посмотреть на Github. Создадим проект согласно иструкции с сайта Vue.js. Ответы «Yes» — только в вопросах «Add ESLint for code quality?» и «Add Prettier for code formatting?». Настрою проект так, как мне удобно. Добавим папку “css” в “src” и создадим там два файла (все пути в коде далее означают нахождение файлов внутри папки “src” Удаляем всё из папок “assets” и “components”. Изменяем App.vue: Набираем в командной строке git init и делаем первый коммит “start project”. Добавим media queries:    В файле «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:  Все изменения до этого момента — в коммите «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 значениями: Я сокращаю и другие названия CSS-свойств, но их описывать не буду — названия понятны из контекста. Нам нужно реализовать интерактивный выбор пользователя. Я скопировал данные из прайс-листа, слегка их преобразовал и сохранил в файл “./store-constant/table-data.js”. Файл можно скачать из коммита “table-data”. Чтобы не перегружать главный компонент App.vue, создадим ButtonsForTable.vue и напишем в нем код, ориентируясь на семантическую верстку: Импортируем созданный компонент в App.vue: Поработаем со стилями. Раньше я использовал SCSS, но теперь мне нравятся CSS-модули. Добавим в App.vue: Создадим CSS-модули:   И изменим ButtonsForTable.vue: Смысл всего сделанного: все 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: В <script setup> мы сначала определели API (через defineProps и defineEmits), затем идет функция clickToButton(), реагирующая на событие и отдающая индекс нажатой кнопки, затем - функция seekClass(), которая определяет, является ли кнопка нажатой/не нажатой, и первая ли в ряду, и в зависимости от этого отдает соответствующие классы из API. Все, наш компонент готов для использования не только в этом, но и в других проектах, можно его загрузить на Гитхаб и использовать на здоровье. Теперь задействуем созданный компонент в нашем проекте. Сначала займемся стилями. Изменения / новые файлы: Создадим классы, общие для всех кнопок:  А после этого — для кнопок в двух цветовых гаммах: Переходим к js: Стили, определенные в классах в модулях buttons-radio-color.module.css и buttons-radio-grey.module.css, мы сразу грузим как classes соответственно в body-type.js и price-type.js, в props. Теперь осталось загрузить последние в созданный ранее компонент ButtonsForTable.vue и подключить там универсальный “черный ящик” CommonButtonsRadio.vue: Пробуем запустить и видим, что получилось: Все работает, нажимается и изменяется. Адаптивной таблицы, правда, пока нет, но мы уже разбираемся в CSS-модулях, знаем про компонентно-ориентированный подход и умеем создавать компоненты, который можно использовать в дальнейшем. И все эти скиллы помогут нам в работе с таблицей. К ней, наконец, и приступим. Все изменения в проекте до этого момента  —  в коммите “CommonButtonsRadio” Таблицу мы также будем делать “черным ящиком”, чтобы можно было ей потом пользоваться. Начнем с главного  —  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: Займемся props для нашего компонента с таблицами. Заведем для этого отдельный файл table-props.js и используем данные для таблицы, подготовленные ранее в “./store-constant/table-data”: И в этом же файле займемся стилями. Сформируем классы для тега, оборачивающего таблицу: Если у нас desktop-отображение, то у границы будут паддинги справа и слева, если mobile  —  таблица во всю ширину экрана. Теперь стили ячеек: У всех ячеек (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: И подключим переменную и новый компонент в App.vue Коммит “CommonTableAdaptive: props, API” Начало положено! Отобразим таблицу в классическом представлении. Также будем ориентироваться на семантическую верстку, и поможет нам в этом CSS Grid. Концепция <template> такая: Классические для таблицы теги <table>, <tr>, <th>, <td> я не стал использовать хотя бы по двум причинам: бóльшая вложенность, больше тегов для одной задумки, о которой речь пойдет ниже, мне нужно, чтобы ячейки шапки таблицы и ячейки самой таблице находились в одном контейнере на одном уровне вложенности. Начнем с самого простого  — <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. Посчитаем их: Количество колонок мы высчитали, пора задать тегу <section> стили и классы: Теперь, если мы запустим npm run dev в командной строке, и понажимаем на кнопки “Saloon / Hatchback” / “Estate”, в консоли браузера можно увидеть, как динамически изменяется свойство grid-template-columns у <section>. Значит, работает как надо :) headTitles, headCols, titles и cells в props  — массивы из строк таблицы, каждая строка — массив из ячеек. Ячейка может быть либо строкой, либо числом, либо объектом. Нужна функция, преобразовывающая все ячейки в объекты. Напишем ее в отдельном модуле: Шапка таблицы состоит из двух частей — headTitles и headCols. Займемся первой: Теперь у нас каждая ячейка  — объект.  В классическом представлении шапка заголовков строк headTitles размещена слева, шапка остальных ячеек — справа. Но у этих частей шапки может быть разное число строк, и у нас как раз такой случай. Решим эту задачу: Функция addRowsToHead() принимает массив из строк и разницу длин строк, и преобразует ячейки первой строки массива  — там появляется ключ rows, если его не было, иначе значение этого ключа увеличивается на разницу строк headTitles и headCols. Добавим новую функцию в seekHeadTitles: В консоли браузера при запущенном dev-режиме мы видим, что у всех ячеек-объектов появилось rows: 2. Отлично, думаем дальше. Теперь нам нужно массив из строк, которые массивы из ячеек, преобразовать в массив из ячеек. Но при этом нужно дать ячейкам знания, являются ли они первыми/последними в ряду и колонке. Кроме того, ячейке нужно передать тип блока, к которому она относится, чтобы впоследствии подключить соответствующие классы. И эта функция также пригодится для шапки колонок таблицы. Напишем ее: Функция принимает массив из строк (которые массив из ячеек), и объект, в котором передается тип блока, а также перечислено, нужно ли отмечать первые/последние ячейки в рядах/колонках, и тип блока таблицы (кроме rowLast  — последней ячейки в колонке таблицы, потому что шапка таблицы никогда будет последним рядом). Каждая ячейка преобразуется в соответствии с этими параметрами, и на выходе мы получаем массив из этих ячеек. Подключим написанные функции в seekHeadTitles: В консоли браузера при запущенном dev-режиме мы видим, что у всех ячеек-объектов появилось rows: 2, cellType: 'head' и у каких надо ячеек  — colFirst: true, rowFirst: true. Примерно все то же самое делаем для блока headCols — шапки ячеек таблицы: Теперь нам нужно преобразовать полученные ячейки-объекты в то, что можно будет передать в <template>. Напишем еще одну функцию 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: И смотрим, что получилось: Все изменения  — в коммите “Classic table: head” Займемся остальными ячейками таблицы. Создадим новый файл для этого: В экспортируемой функции seekTableCells мы трансформируем ячейки в массивах props.titles и props.cells в объекты, после этого разбираем props.cells по строкам таблицы, сначала по номеру строки таблицы добавляем в массив ячейки из соответствующего ряда props.titles, и затем - из ряда props.cells. К тем ячейкам, где это необходимо, добавляем ключи colFirst, colLast и rowLast (rowFirst быть не может, потому что выше  — шапка таблицы). Импортируем написанное в TableTemplate.vue: Смотрим, что получилось… Упс, у нас неправильно отображаются последние ячейки в колонке (↓). А если мы проверим первые ячейки в ряду (←), то там тоже будет ошибка. Все дело в том, что мы определяем их по индексу ячейки в массиве строки и индексу строки в массиве блока. То есть у массива rows с длиной length = 2 colFirst = true у элементов 1 и 3 (их индекс в массивах row === 0, и rowLast у элементов 3 и 4 (индекс строки, в которой они состоят, равен rows.length - 1). Но в нашем случае может быть так: Здесь первая ячейка в первом массиве row занимает два места, “растягивается” на следующую строку. И у нее должно быть rowLast = true, и описанная логика не срабатывает. И следующая ошибка — у элемента 4, у которого colFirst = false, хоть он и первый в своем ряду. Напишем правильную функцию для определения, является ли ячейка первой в нашей таблице. Вынесем ее в отдельный файл, потому что эта же ошибка актуальна для шапки таблицы, хоть она там и не проявилась: Сначала (вне функции) заводим счетчик prevColFirst = 0. Сама функция checkColFirst принимает два аргумета — ячейку-объект (нас интересует только ее ключ rows, поэтому вместо cell мы пишем { rows }, и индекс этой ячейки в строке ряду. Если iCol === 0 (первая ячейка в строке) — смотрим на счетчик prevColFirst. Если он равен нулю, значит исследуемая ячейка — первая в ряду. Если у нее есть ключ rows, значит ячейка “растягивается” вниз, и мы устанавливаем prevColFirst = rows - 1 — триггер для следующих первых ячеек. Если prevColFirst !== 0, значит ячейка первой в ряду не является, даже если она первая в массиве. В таком случае уменьшаем prevColFirst на единицу. Подключаем написанную функцию: Теперь разберемся с rowLast. Этот ключ нам нужен только для seekTableCells, так что мы можем записать функцию в seek-table-cells.js: Функция checkRowLast принимает аргументы: cell (нас интересует rows), iRow - индекс строки таблицы и rowsLength - кол-во строк в таблице. Последняя ячейка в колонке - если iRow === rowsLength - 1 или если iRows + rows === rowsLength Проверяем — все замечательно: Коммит “Classic table: all cells” Приступим к адаптации таблицы для небольших экранов (неважно каких устройств, мобильных или нет). Заведем вычисляемую переменную и будем ее вовремя обновлять, и вовремя на нее реагировать: Вычисляемая переменная isTableWide показывает нам, надо ли показывать таблицу в классическом (широком) виде, или ее нужно адаптировать. Функция watchTableWidthWithArg реагирует на изменение окна браузера. Если новая ширина браузера не равна старой clientWidthOld, происходит сброс таблицы к классическому виду (refTableWide.value = true), и с нулевой задержкой setTimeout (она нужна, чтобы у dom-элемента успели появиться новые данные, в т.ч. интересующая нас ширина) мы сравниваем, выходит ли таблица за границу экрана (ширина таблицы > ширины окна браузера). Если выходит — refTableWide.value = false. Запускаем эту функцию по vue-событию onMounted и по событию ‘resize’ у window. Важно также добавить отслеживание isTableWide на вотчер watch, по которому запускается расчет колонок таблицы setColsNumber. Теперь мы знаем, когда нужно показывать широкую таблицу, а когда - узкую. С сеттера расчета колонок и начнем адаптацию таблицы: Если таблица широкая, кол-во колонок в ней складывается из колонок props.titles и props.cells, если узкая  — считаются только колонки в props.cells Изменим шапку таблицы. Начнем с шапки заголовков строк: В файл мы импортировали две переменных, isTableWide и getColsNumber. В условие, по которому выполняется функция addRowsToHead, мы добавили && isTableWide.value — т.е. это происходит только если таблица широкая. В противном случае (rows && !isTableWide.value) мы трансформируем каждую строку, состоящую из ячеек: складываем там все ячейки в одну, перечисляя текст через запятую, и этой одной ячейке присваиваем cols, равное общему количеству ячеек таблицы (растягиваем получившуюся одну ячейку на всю ширину таблицы). Ну и добавляем ключ colLast к аргументу функции transformRowsToCells со значением !isTableWide.value. Т.е. если таблица узкая, то последняя в строке ячейка становится последней ячейкой таблицы по горизонтали. Шапка ячеек таблицы меняется не сильно: Когда таблица широкая, headCols размещается сверху слева в таблице. Для ее ячеек важны rowFirst и colLast. При узкой таблице headCols вверху, только если нет headTitles (поэтому rowFirst: isTableWide.value || !propsHeadTitles.length), и шапка ячеек занимает всю ширину таблицы (поэтому colFirst: !isTableWide.value). В классическом представлении ряд (строка) таблицы формируется так: в левой части (сначала) идут ячейки ряда props.titles, в правой части (затем) — ячейки props.cells. Когда таблица узкая, ячейки ряда props.titles складываются в одну и занимает всю ширину таблицы, а ячейки props.cells размещаются под ней: Ячейка таблицы может объединяться со следующими ячейками в колонке, если у нее есть ключ rows. При адаптации (!isTableWide) нам такое объединение ячеек не нужно из-за поведения titles. Напишем функцию, которая исключит 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:  Если таблица узкая — запускаем transformCellsWithoutRows, чтобы убрать  rows в ячейках-объектах и продублировать их столько раз, сколько нужно. Если таблица узкая — объединяем ячейки в ряду propsTitles[iRow] в одну, задаем им cols = getColsNumber.value — растягиваем ячейку на всю ширину, поэтому эта ячейка будет одновременно первой и последней в своей строке (colFirst: true, colLast: true). У блока cells, если таблица узкая, ячейка — первая в ряду таблицы, если она первая в своей строке. Смотрим, что получилось: Всё работает так, как и задумано, таблица адаптируется под маленькие экраны. Таблица интерактивна — данные меняются в зависимости от выбора пользователя. Мало того, на любых экранах таблица компактна — занимает минимум места, что нисколько не мешает восприятию информации, а самое главное — сохраняется главное достоинство таблицы — отображенные в ней данные легко сравнивать друг с другом! Коммит “Adaptive table” На этом статью можно было бы и завершить. Но что если колонок в таблице намного больше? Это не очень хорошо для восприятия, и скорее всего данные в такой большой таблице можно сократить, но все-таки? В случае, если колонок много в левой части классической таблице — titles, проблем не возникает: в компактном представлении все эти колонки “схлопываются” в одну, которая становится “подшапкой” строки cells с ячейками, где представлены данные для сравнения. Но что, если этих ячеек настолько много, что они не умещаются в одну строку на маленьком экране? Сделаем адаптацию для бесконечного количества столбцов в cells. Концепция такая: если в строке cells ячейки не помещаются в экран браузера, то одна строка трансформируется в несколько, как на рисунке: Здесь есть две сложности, которые нужно учесть: Если в строке есть ячейка, объединяющая несколько колонок, и разделение на подстроки происходит как раз по этим колонкам (“2 columns” на рисунке), то в компактном представлении нужно будет ее дублировать на разделяемые строки Если после разделения подстроки в последней строке окажется меньше ячеек, чем в предыдущих (“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: Заведем вычисляемую переменную RowsSplitter: И изменим функцию watchTableWidthWithArg: Мы переименовали функцию 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-элементом: Теперь нам нужна функция, которая найдет все ширины колонок в cells, которые тоже стали dom-элементами: Функция 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: В функции seekRowsSplitter заводим массив matrix, который будет состоять из подстрок. В новую (последнюю в массиве) подстроку добавляем ячейку с известной шириной из widths до тех пор, пока эта подстрока умещается в широину экрана, или сумма ширин элементов этой подстроки <= window.clientWidth. Если больше — заводим новую подстроку. В результате мы получаем то, что нужно: длина массива matrix — и есть количество подстрок, на которую нужно разделить каждую строку cells. Изменим вычисляемую переменную, отвечающую за кол-во колонок таблицы: Мы добавили условие (которое выполняется при !isTableWide.value): если нужно строки cells делить на подстроки, то общее количество колонок делится на кол-во этих подстрок с округлением в большую сторону. Осталось совсем немного — разделить cells на подстроки. Напишем функцию splitRows: Пока эта функция достаточно проста: массив rowsNew состоит из строк, длина которых не больше getColsNumber, а в getColsNumber мы уже учитываем количество подстрок getRowsSplitter. Каждую ячейку мы добавляем в конец последней строки в rowsNew. Счетчик countCol с каждой ячейкой растет либо на единицу, если эта ячейка не объединена с другими, либо на cols ячейки-объекта. Как только счетчик вырастает до количества колонок getColsNumber, счетчик обнуляется, и заводится новая строка в rowsNew. Но есть две проблемы, которые обозначены в начале этой главы, и даже нарисованы (см. в последнем изображении “2 columns” и “last column”. Решим эти проблемы: Повторюсь — ячейка в конец последней строки rowsNew добавляется до тех  пор, пока счетчик countCol не равен количеству колонок в таблице  getColsNumber.value, иначе заводится новая строка в rowsNew и счетчик  обнуляется. Если же нам нужно добавить ячейку, у которой есть cols, и  cols + countCol > getColsNumber.value — значит эта ячейка, которая  занимает несколько колонок, находится на стыке разрыва строк, и ее нужно  дублировать, что и происходит в цикле while. Теперь представим пример: у cell — 7 колонок. На маленьком экране помещается только 4. Каждая строка cell делится на две подстроки, в первой — 4 ячейки, во второй — три. Поскольку у нас табличное представление, надо последнюю ячейку второй строки растянуть на две. Добавим код в функцию, который будет делать подобное: Отлично, теперь функция splitRows разбивает каждую строку cells на подстроки, если строки не умещаются целиком на экране. Изменим код в модуле, который трансформирует ячейки из sells: Применим написанную функцию и к шапке ячеек: Если у нас адаптивная таблица (!isTableWide.value) и есть разделение строк на подстроки (getRowsSplitter.value > 1), мы смотрим на первую строку шапки. Если она состоит всего из одной ячейки (т.е. растягивается на все колонки в cells), мы ее временно удаляем из шапки, заодно присваивая ей cols, равное количеству колонок с учетом getRowsSplitter. Оставшиеся строки шапки преобразуем также, как и cells до этого. Однако теперь нам нужно раскидать строки через одну, что и делает функция transformRowsThroughOne. Ну и если мы нашли строку с одной ячейкой, прикрепляем ее к шапке обратно. Все работает как надо: Коммит “Many Columns” Таблицы с небольшим количеством ячеек в cells выглядят неплохо, однако  чем больше ячеек — тем массивнее шапка, и тем хуже сравнивать данные.  Большого количества ячеек следует избегать, но случаи разные бывают.  Добавим последний штрих, улучшающий восприятие какой угодно по размеру  таблицы. В CSS-Grid есть хорошее свойство, которое сделает нашу таблицу более приятной для глаз — “grid-row-start”. Добавим прибавление этого свойства в функцию transformCellToTemplate, которая подготавливает ячейки-объекты для <template>: Создадим вычисляемую переменную HeadIndent: Функция setHeadIndentFromDom принимает dom-элемент <section> — оборачивающий тег нашей таблицы, и количество строк в cells из props. Когда таблица только загрузилась в dom-дерево, мы можем вычислить высоту <section> относительно окна браузера. Это условный 0%, начало таблицы (topMin). 100% (topMax) — когда нижняя граница таблицы видна в окне браузера. Зная topMin и topMax и текущий top относительно окна браузера при скролле, мы можем рассчитать, на сколько процентов таблица поднялась вверх. А зная количество строк в cells, мы можем определить, к какой строке cells подвинуть шапку таблицы при скролле. Импортируем созданное: Создадим функцию addHeadIndent: Эта функция будет добавлять к ячейкам шапки ключ indent с необходимым значением. Проследив на видео ниже за поведением шапки таблицы,станет понятно, что эта функция делает. Импортируем ее в функции, которые работают с ячейками шапки: Коммит “Moving Table Header” Смотреть, что получилось, лучше в движении: или на веб-странице.