Table
Таблиця зі сортуванням, станами завантаження і мобільними картками з тих самих слотів.
Приклад
- Сторінка
- Розділ 9
- Перегляди
- 22 910
- Статус
- опубліковано
- Сторінка
- Розділ 2
- Перегляди
- 1 284
- Статус
- опубліковано
- Сторінка
- Вступ
- Перегляди
- 431
- Статус
- чернетка
- Сторінка
- Розділ 10
- Перегляди
- 87
- Статус
- чернетка
Сортування: views / desc — третій клік по заголовку скидає.
Налаштування колонок
Задайте tableId — і вмикається все одразу: ресайз межею заголовка, меню
видимості й порядку, перемикач щільності та збереження в localStorage.
- SKU
- AB-1
- Назва
- Кабель USB-C 2 м
- Постачальник
- Мережа Плюс
- Ціна
- 249 ₴
- Залишок
- 12
- Примітка
- Доставка з Києва протягом доби. Залишок оновлюється щогодини.
- SKU
- AB-10
- Назва
- Кабель USB-C 0.5 м
- Постачальник
- Мережа Плюс
- Ціна
- 149 ₴
- Залишок
- 3
- Примітка
- Мала партія.
- SKU
- AB-9
- Назва
- Перехідник HDMI
- Постачальник
- Технохаб
- Ціна
- 599 ₴
- Залишок
- 0
- Примітка
- Немає в наявності, очікується поповнення наступного тижня.
- SKU
- CD-2
- Назва
- Док-станція 7-в-1
- Постачальник
- Технохаб
- Ціна
- 2 490 ₴
- Залишок
- 5
- Примітка
- SKU
- CD-7
- Назва
- Хаб USB-C 4 порти
- Постачальник
- Мережа Плюс
- Ціна
- 890 ₴
- Залишок
- 27
- Примітка
- Хіт продажів сезону.
- SKU
- EF-3
- Назва
- Зарядний пристрій 65 Вт GaN
- Постачальник
- Технохаб
- Ціна
- 1 290 ₴
- Залишок
- 8
- Примітка
- Компактний блок із двома USB-C.
Потягніть межу заголовка, щоб змінити ширину; подвійний клік скидає. Наведіть на таблицю — у правому куті шапки з'явиться кнопка видимості, порядку й щільності. Усе зберігається й переживає перезавантаження. Прокрутіть таблицю вбік: колонка SKU лишається на місці, решта їде під неї, а кнопка — біля правого краю.
Кнопка налаштувань не займає ані рядка, ані колонки: вона живе в правому куті шапки й проявляється, щойно курсор над таблицею. Більшість часу її ніхто не торкається, і окремий рядок над таблицею заради однієї іконки — висота, відібрана в даних. Колонка під кнопку була б іще гіршою: порожня комірка в кожному рядку — мертва смуга вздовж усієї таблиці.
Кут — sticky-елемент тієї самої клітинки сітки, що й таблиця. Він тримається правого краю видимої області за будь-якої горизонтальної прокрутки, а з липкою шапкою — ще й верху. Без липкої шапки кнопка їде вгору разом із нею: налаштування належать шапці, а не рядкам. Під іконкою — смуга кольору шапки з м'яким краєм, тож заголовок, що опинився під кутом, згасає, а не перетинається з іконкою.
Прихована до наведення — не означає недосяжна:
- Клавіатура. Tab дістає кнопку першою, ще до рядків таблиці, і вона проявляється разом із кільцем фокуса.
- Дотик. Наведення там не буває, тож кнопку видно завжди, а остання колонка шапки віддає їй місце — інакше праворуч вирівняний підпис («Сума») назавжди лишився б під іконкою.
- Схована колонка. Єдиний стан розкладки, якого не видно в самій
таблиці: порядок і ширини читаються оком, відсутність — ні. Тому
кнопка з крапкою, і крапку видно навіть у спокої — вона показує, де
шукати шлях назад. Колонки, сховані вашими дефолтами (
visible: false), не рахуються: це ваше рішення, а не зміна користувача.
На мобільних картках шапки немає, тож немає й кута: кнопка стає в рядок над картками, поруч із сортуванням.
Сама панель — спільний компонент із UiTreeTable
(app/components/ui/table/ColumnSettings.vue), як і арифметика ширин,
порівняння значень при сортуванні й зведення збереженої розкладки
(app/utils/tableColumns.ts). Це не економія рядків, а гарантія: дві
копії тієї самої панелі розходяться мовчки й розходилися — у дереві
кнопки порядку мали зону дотику 45×45 і SVG-стрілки, тут лишались
текстові «↑↓» без зони.
Закріплена перша колонка
stickyColumn тримає першу видиму колонку на місці при горизонтальній
прокрутці — той самий контракт, що stickyTreeColumn у UiTreeTable, і
той самий вигляд: край колонки отримує тінь, щойно під нею починає їхати
вміст. Має сенс лише тоді, коли перша колонка ідентифікує рядок — назва,
SKU, номер; закріплена колонка зі статусом лишає читача без орієнтира.
З selectable колонка прапорців теж закріплюється — інакше перша ж
прокрутка ховала б її під колонкою даних.
Стани
Вибір рядків
selectable додає колонку прапорців, v-model:selected тримає ключі
обраних рядків. Ключ береться з того самого поля, що вже дає рядкам :key
— окремого props для цього немає й не треба.
- Клієнт
- ТОВ «Сігма Трейд»
- Статус
- В роботі
- Сума
- 420 000 ₴
- Клієнт
- ФОП Коваленко О. П.
- Статус
- Виграно
- Сума
- 86 500 ₴
- Клієнт
- ПрАТ «Дніпро-Логістик»
- Статус
- В роботі
- Сума
- 1 240 000 ₴
- Клієнт
- ТОВ «Аграрій Плюс»
- Статус
- Втрачено
- Сума
- 54 000 ₴
- Клієнт
- ТОВ «Медтехніка»
- Статус
- В роботі
- Сума
- 315 000 ₴
Shift+клік виділяє діапазон. Втрачену угоду обрати не можна — selectableRow її виключає.
Три рішення, які тут неочевидні.
«Обрати всі» об'єднує, а не замінює. Таблиця не пагінує сама:
items — це вже сторінка, а UiPagination
живе окремо. Якби прапорець у шапці замінював набір, користувач, що вибрав
рядки, перейшов на другу сторінку й натиснув «обрати всі», мовчки втратив
би вибір із першої. Тому шапка додає ключі сторінки до набору й забирає їх
назад — ключі інших сторінок лишаються цілі.
Shift+клік ставить діапазону стан якоря, як у Finder і Gmail, а не перемикає кожен рядок окремо: почергове перемикання дає результат, який неможливо передбачити оком. Якір — останнє перемикання без Shift. Діапазон рахується в порядку показу, тож після сортування Shift виділяє те, що видно між двома рядками, а не те, що лежало між ними у вихідних даних.
Виділення не зберігається. Ширини й видимість колонок ідуть у
localStorage, виділення — ні: набір обраних записів живе рівно стільки,
скільки триває дія над ними.
Вибір рядків не забороняє копіювати текст. Раніше selectable вішав
select-none на всю таблицю — лише щоб Shift+клік не виділяв текст через
пів таблиці, — і з неї не можна було скопіювати жодного номера. Тепер
виділення тексту гаситься тільки на самому жесті Shift+mousedown по
прапорцю.
Сортування виділенню не шкодить: воно ключується по keyRow, а не по
індексу, тож перевпорядкування рядків його не плутає.
API
Props
| Назва | Тип | Типово | Опис |
|---|---|---|---|
headers* | TableHeader[] | — | Опис колонок: ключ, підпис, вирівнювання, ширина, сортованість. |
items* | T[] | — | Рядки таблиці. Ключ рядка береться з поля, названого в `keyRow`. |
keyRow | string | "id" | Поле-ідентифікатор рядка для `:key`. |
sort | TableSort | null | Поточне сортування. Використовуйте через `v-model:sort`. |
skeletonRows | number | 5 | Скільки рядків-заглушок показати під час ПЕРШОГО завантаження. |
emptyText | string | "Даних немає" | Текст, коли рядків немає. Складніший стан — слот `empty`. |
density | smmd | "md" | Щільність рядків. `sm` для довгих таблиць, де важливіше бачити більше рядків. |
rowClass | (item: T) => string | — | Клас на рядок — для підсвітки виділених, помилкових тощо. З `tableId` рядок отримує власне тло `bg-card`, тож заливка звідси має бути утилітою, яка в CSS іде після `bg-card` — усі семантичні токени (`bg-warning-bg`, `bg-danger-bg`, `bg-primary-50`) підходять. |
maxHeight | string | — | Напр. `"24rem"`. Без нього `stickyHeader` не має де закріплюватись. |
tableId | string | — | Вмикає меню налаштувань і збереження розкладки в localStorage під ключем `table_settings_${tableId}`. Без нього таблиця некерована користувачем і нічого не запам'ятовує. |
settingsVersion | number | 0 | Версія ВАШИХ дефолтів. Змінили ширини чи видимість у `headers` — підніміть число, і збережений вибір користувача скинеться. |
selected | SelectionKey[] | [] | Ключі обраних рядків. Використовуйте через `v-model:selected`. |
selectableRow | (item: T) => boolean | — | Які рядки взагалі можна обрати. Незбиральний рядок показує вимкнений прапорець і не потрапляє ні в «обрати всі», ні в діапазон Shift. |
serverSort | boolean | — | Сортувати на сервері: компонент лише повідомляє про намір через `update:sort`, але порядок рядків не чіпає. |
loading | boolean | — | Показує скелетони замість рядків, зберігаючи висоту таблиці. |
densityToggle | boolean | true | Показати перемикач щільності в меню налаштувань. |
rowClickable | boolean | — | Робить рядки клікабельними: додає роль, фокус і обробку Enter/Space. |
mobileCards | boolean | — | Нижче `md` таблиця ховається, а замість неї рендериться список карток — із ТИХ САМИХ слотів `cell-*`. Одне API, дві верстки. |
stickyHeader | boolean | — | Закріпити шапку. Вимагає `maxHeight` або `fill`, інакше не діє. |
stickyColumn | boolean | — | Закріпити ПЕРШУ видиму колонку при горизонтальній прокрутці — той самий контракт, що `stickyTreeColumn` у `UiTreeTable`. Має сенс лише коли перша колонка ідентифікує рядок (назва, номер). |
fill | boolean | — | Висоту задає БАТЬКО, а не число тут. Контейнер прокрутки обіймає вміст, поки той вміщається, і стискається до залишку висоти, щойно перестав, — тож пагінація під таблицею лишається на екрані замість їхати за нижню межу вікна, а під короткою таблицею не висить порожня смуга на третину екрана. Вимагає ланцюжка flex-колонок із ВИЗНАЧЕНОЮ висотою (`h-dvh`, `h-full`) і `min-h-0` на кожній ланці. Без такого ланцюжка стискати немає до чого, і таблиця рендериться на всю свою висоту — саме тому `fill` безпечний і на сторінці, що прокручується: там він просто знімає кліть `maxHeight` і нічого не ламає. Перекриває `maxHeight`. |
selectable | boolean | — | Вмикає колонку прапорців ліворуч. Ключем виділення служить той самий `keyRow`, що вже дає рядкам `:key`. |
selectionBar | boolean | true | Панель «Вибрано N» над таблицею. Вимикайте, коли масові дії живуть у власному тулбарі споживача. |
Події
| Назва | Payload | Опис |
|---|---|---|
update:sort | [value: TableSort] | Нове сортування або `null`, якщо скинуто. Використовуйте через `v-model:sort`. |
update:headers | [value: TableHeader[]] | Розкладка колонок змінилася — видимість, порядок або ширина. |
update:selected | [value: SelectionKey[]] | Ключі обраних рядків. Використовуйте через `v-model:selected`. |
rowClick | [item: T] | Активація рядка кліком, Enter або Space. Лише коли задано `rowClickable`. |
Слоти
| Назва | Опис |
|---|---|
mobile-card | Вміст картки нижче `md`, якщо стандартний список пар не підходить. |
empty | Показується замість «Даних немає». |
selection-actions | Дії в панелі «Вибрано N». `clear` знімає виділення. |
Доступно через ref
| Назва | Тип | Опис |
|---|---|---|
clearSelection | () => void | Знімає виділення. Потрібне після успішної масової дії. |
selectAllOnPage | () => void | Обирає всі доступні рядки поточного `items`. |
Коли використовувати
Для однорідних даних, які порівнюють між собою по колонках.
mobileCards вмикайте завжди, коли колонок більше трьох: горизонтальна
прокрутка таблиці на телефоні — найгірший спосіб читання даних.
Шапки в картках немає, тож сортування живе в окремому рядку над ними:
нативний <select> із колонками, що сортуються, і кнопка напрямку. Нативний
— свідомо: телефон відкриває системний вибір, зручніший за будь-яку
випадайку. Рядок з'являється сам, щойно хоч одна видима колонка
sortable, і пише в той самий v-model:sort.
Коли НЕ використовувати
Не робіть таблицю з двох колонок «назва / значення» — це список описів
(<dl>), і на мобільному він виглядатиме краще без жодних зусиль.
Ширини живуть у colgroup
width — це число в пікселях, а не CSS-рядок. Ширини задаються один раз у
<colgroup> при table-layout: fixed, а не на кожній комірці: за фіксованої
розкладки враховується лише перший рядок, тож інлайновий width на кожному
<td> був би мертвим стилем, помноженим на кількість рядків.
Рівно одна колонка може мати flex: true — вона забирає весь залишок. Сума
фіксованих ширин має вміщатись у контейнер.
Число, а не рядок, ще й тому, що ширина бере участь в арифметиці: ресайз,
збереження, підрахунок переповнення. З "9rem" нічого з цього не порахуєш.
Висота: maxHeight чи fill
maxHeight — стеля в абсолютних одиницях: контейнер обіймає вміст, поки той
нижчий за неї. Працює будь-де, але нічого не знає про сторінку, на якій
стоїть. Типове max-height: 70vh під шапкою, фільтрами й пагінацією дає два
дефекти одразу: знизу лишається смуга порожнечі, якої ніщо не заповнює, а
останній рядок ріжеться навпіл там, де до низу екрана ще третина висоти.
fill віддає рішення про висоту батьківському контейнеру. Контейнер
прокрутки обіймає вміст, поки той вміщається, і стискається до залишку
висоти, щойно перестав: пагінація під таблицею лишається на екрані, а
таблиця на три рядки не розтягується порожнім тлом на весь екран.
Ціна — ланцюжок. Висота має бути ВИЗНАЧЕНОЮ згори (h-dvh на каркасі, не
min-h-dvh), а кожна ланка між каркасом і таблицею — flex-колонкою з
min-h-0. Пропущений min-h-0 не дає ані помилки, ані попередження:
елемент просто не стискається, і таблиця тихо рендериться на всю висоту.
<div class="flex h-dvh flex-col">
<main class="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-3">
<section class="flex min-h-96 flex-1 flex-col gap-3">
<PageHeader />
<UiTable fill sticky-header :headers="headers" :items="items" />
<UiPagination v-model:page="page" :total-pages="pages" />
</section>
</main>
</div>
min-h-96 на секції — не окраса. Таблиця з fill єдина в колонці має
min-h-0, тож вона забирає ВЕСЬ дефіцит висоти: у вікні 1280×320 без
підлоги від неї лишається сама шапка. Підлога стоїть на секції, а не на
таблиці, саме тому, що на таблиці вона тримала б порожнечу під короткими
списками; на секції зайва висота йде під пагінацію, де її не видно, а
переповнення бере на себе overflow-y-auto на <main>.
Саме тому fill безпечний і на сторінці, що прокручується: визначеної
висоти згори там немає, стискати немає до чого, і властивість просто знімає
кліть maxHeight — таблиця показує всі рядки, а гортає сторінка. Для
таблиці, вбудованої в довгу сторінку, це НЕ завжди те, що треба: якщо
колонки не вміщаються по ширині, горизонтальний скролбар поїде в самий низ
високого блока. Там лишайте maxHeight.
Одне API, дві верстки
Нижче md таблиця ховається, а картки рендеряться з тих самих слотів
cell-*. Розмітку комірки не треба писати двічі:
<UiTable :headers="headers" :items="items" mobile-cards>
<template #cell-status="{ item }">
<UiChip :tone="item.status === 'published' ? 'success' : 'neutral'">
{{ item.status }}
</UiChip>
</template>
</UiTable>
Сортування
Порівняння враховує числа й локаль. Наївні < і > ставлять «Розділ 10»
перед «Розділ 9», а кирилицю сортують за кодами символів — перевірте це на
прикладі вище.
Порожні значення завжди в кінці, незалежно від напрямку: рядок без даних не має витісняти заповнені з початку списку.
Date порівнюється за часом. Без цього дата падала в рядкове порівняння, і
toString() сортував за назвою дня тижня — «Fri» перед «Mon» перед «Thu».
Невалідна дата вважається порожньою.
Коли в комірці не те, що треба порівнювати, задайте колонці sortValue:
вкладене поле, код статусу з власним порядком, рядкова дата «24.09.2026».
Значення читається один раз на рядок, а не на кожне порівняння.
const headers: TableHeader[] = [
{ value: 'author', text: 'Автор', sortable: true, sortValue: (item) => item.author.name },
{ value: 'status', text: 'Статус', sortable: true, sortValue: (item) => STATUS_ORDER[item.status] },
]
Тип рядка — звичайний interface: обмеження генерика Record<string, any>
приймає його без extends Record<…>.
Третій клік по заголовку скидає сортування. Без цього повернутися до вихідного порядку можна було б лише перезавантаженням сторінки.
Для серверного сортування задайте serverSort: компонент повідомить про
намір через update:sort, але порядок рядків не чіпатиме.
Стани
Скелетон показується лише при першому завантаженні. Якщо дані вже є, замість нього накладається напівпрозорий оверлей — інакше таблиця блимала б на кожній зміні фільтра.
Ширина заглушок детермінована, а не випадкова: Math.random() міняв би її
на кожному рендері, і скелетон миготів би.
Мобільні картки мають ті самі стани: скелетон-картки при першому
завантаженні, оверлей «Оновлення…» при повторному, слот empty — коли
даних немає. Раніше телефон показував порожнечу на весь час першого запиту
(скелетон жив у схованій таблиці), а замість слота empty — голий
emptyText.
Доступність
Заголовки колонок мають scope="col" і aria-sort. Сортування вмикається
кнопкою всередині <th>, а не кліком по самому <th> — інакше воно
недоступне з клавіатури.
rowClickable лишає рядок рядком: він потрапляє в Tab-обхід і
активується Enter, Space чи кліком, але БЕЗ role="button". Ця роль на
<tr> стирала семантику таблиці — комірки переставали бути пов'язаними із
заголовками, а прапорець і кнопки в комірках опинялися вкладеними в
кнопку, тобто для скрінрідера зникали.
Контроли всередині рядка мають власну дію. Enter чи клік по кнопці або
посиланню в cell-* не відкриває рядок — раніше рядок перехоплював Enter
і скасовував активацію самої кнопки. Так само не відкриває рядок виділений
мишею текст: це копіювання, а не клік.
Фокус клікабельного рядка малюється на комірках. Кільце на самому <tr>
не було видно ніколи: позиційовані комірки (роздільник, sticky) малюються
поверх тіні рядка разом зі своїм непрозорим тлом.
stickyHeader вимагає контейнера з власною прокруткою — тобто
maxHeight або fill. Без жодного з них закріплювати шапку немає відносно
чого, і властивість мовчки нічого не робить.
Кнопка налаштувань ховається через opacity, а не v-if чи
visibility: вона лишається в Tab-обході й у дереві доступності. Мітка
називає кількість схованих колонок — «Налаштування колонок (приховано:
2)», — тож крапку на кнопці чує й скрінрідер. Зона дотику — 45px
завширшки, але обрізана по висоті шапки: у щільній шапці (27px) повна
зона залізла б на 9px у перший рядок, і тап по його правому краю
відкривав би налаштування замість рядка.
Дві осі версій збереженого
Найтонше місце всієї таблиці. Збережена розкладка має два незалежні номери версій, бо в ній змішані дві різні речі.
settingsVersion — ваш. Підніміть його, коли змінили дефолтні ширини чи
видимість у headers: збережене більше не описує ту саму таблицю, і тримати
його означало б показувати користувачам чужу розкладку.
SETTINGS_SCHEMA — компонента. Коли міняється формат payload, порядок і
видимість (це вибір користувача) мігрують уперед, а ширини й щільність (це
дефолти компонента) скидаються.
Нова колонка вставляється після найближчого лівого сусіда, а не в кінець. Без цього колонка, додана через пів року, стрибала б у хвіст у кожного, хто колись міняв порядок.
tableId варто передавати динамічно там, де таблиця показує різні сутності
(group-${id}) — компонент стежить і за ним, інакше наступна сутність
відкрилася б із розкладкою попередньої.
Афорданси прокрутки
Переповнення рахується як ширина таблиці − ширина видимої області, а не через
scrollWidth. Так градієнт з'являється і після ресайзу колонки — від нього
розмір контейнера не міняється, тож самого ResizeObserver було б замало.
ResizeObserver при цьому теж потрібен: згортання сайдбара міняє ширину
контейнера без жодної події resize вікна.
Чого тут немає
Віртуального скролу. Для списків у тисячі рядків беріть пагінацію або
серверну фільтрацію. Якщо дані ще й ієрархічні — TreeTable: він
віртуалізує рядки, але вимагає фіксованої висоти рядка, якої тут немає.