Перейти до вмісту
tatetUI

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`.
keyRowstring"id"Поле-ідентифікатор рядка для `:key`.
sortTableSortnullПоточне сортування. Використовуйте через `v-model:sort`.
skeletonRowsnumber5Скільки рядків-заглушок показати під час ПЕРШОГО завантаження.
emptyTextstring"Даних немає"Текст, коли рядків немає. Складніший стан — слот `empty`.
densitysmmd"md"Щільність рядків. `sm` для довгих таблиць, де важливіше бачити більше рядків.
rowClass(item: T) => string—Клас на рядок — для підсвітки виділених, помилкових тощо. З `tableId` рядок отримує власне тло `bg-card`, тож заливка звідси має бути утилітою, яка в CSS іде після `bg-card` — усі семантичні токени (`bg-warning-bg`, `bg-danger-bg`, `bg-primary-50`) підходять.
maxHeightstring—Напр. `"24rem"`. Без нього `stickyHeader` не має де закріплюватись.
tableIdstring—Вмикає меню налаштувань і збереження розкладки в localStorage під ключем `table_settings_${tableId}`. Без нього таблиця некерована користувачем і нічого не запам'ятовує.
settingsVersionnumber0Версія ВАШИХ дефолтів. Змінили ширини чи видимість у `headers` — підніміть число, і збережений вибір користувача скинеться.
selectedSelectionKey[][]Ключі обраних рядків. Використовуйте через `v-model:selected`.
selectableRow(item: T) => boolean—Які рядки взагалі можна обрати. Незбиральний рядок показує вимкнений прапорець і не потрапляє ні в «обрати всі», ні в діапазон Shift.
serverSortboolean—Сортувати на сервері: компонент лише повідомляє про намір через `update:sort`, але порядок рядків не чіпає.
loadingboolean—Показує скелетони замість рядків, зберігаючи висоту таблиці.
densityTogglebooleantrueПоказати перемикач щільності в меню налаштувань.
rowClickableboolean—Робить рядки клікабельними: додає роль, фокус і обробку Enter/Space.
mobileCardsboolean—Нижче `md` таблиця ховається, а замість неї рендериться список карток — із ТИХ САМИХ слотів `cell-*`. Одне API, дві верстки.
stickyHeaderboolean—Закріпити шапку. Вимагає `maxHeight` або `fill`, інакше не діє.
stickyColumnboolean—Закріпити ПЕРШУ видиму колонку при горизонтальній прокрутці — той самий контракт, що `stickyTreeColumn` у `UiTreeTable`. Має сенс лише коли перша колонка ідентифікує рядок (назва, номер).
fillboolean—Висоту задає БАТЬКО, а не число тут. Контейнер прокрутки обіймає вміст, поки той вміщається, і стискається до залишку висоти, щойно перестав, — тож пагінація під таблицею лишається на екрані замість їхати за нижню межу вікна, а під короткою таблицею не висить порожня смуга на третину екрана. Вимагає ланцюжка flex-колонок із ВИЗНАЧЕНОЮ висотою (`h-dvh`, `h-full`) і `min-h-0` на кожній ланці. Без такого ланцюжка стискати немає до чого, і таблиця рендериться на всю свою висоту — саме тому `fill` безпечний і на сторінці, що прокручується: там він просто знімає кліть `maxHeight` і нічого не ламає. Перекриває `maxHeight`.
selectableboolean—Вмикає колонку прапорців ліворуч. Ключем виділення служить той самий `keyRow`, що вже дає рядкам `:key`.
selectionBarbooleantrueПанель «Вибрано 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: він віртуалізує рядки, але вимагає фіксованої висоти рядка, якої тут немає.