Перейти до вмісту
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 лишається на місці, решта їде під неї.

Сама панель — спільний компонент із 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, виділення — ні: набір обраних записів живе рівно стільки, скільки триває дія над ними.

Сортування виділенню не шкодить: воно ключується по 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 вмикайте завжди, коли колонок більше трьох: горизонтальна прокрутка таблиці на телефоні — найгірший спосіб читання даних.

Коли НЕ використовувати

Не робіть таблицю з двох колонок «назва / значення» — це список описів (<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», а кирилицю сортують за кодами символів — перевірте це на прикладі вище.

Порожні значення завжди в кінці, незалежно від напрямку: рядок без даних не має витісняти заповнені з початку списку.

Третій клік по заголовку скидає сортування. Без цього повернутися до вихідного порядку можна було б лише перезавантаженням сторінки.

Для серверного сортування задайте serverSort: компонент повідомить про намір через update:sort, але порядок рядків не чіпатиме.

Стани

Скелетон показується лише при першому завантаженні. Якщо дані вже є, замість нього накладається напівпрозорий оверлей — інакше таблиця блимала б на кожній зміні фільтра.

Ширина заглушок детермінована, а не випадкова: Math.random() міняв би її на кожному рендері, і скелетон миготів би.

Доступність

Заголовки колонок мають scope="col" і aria-sort. Сортування вмикається кнопкою всередині <th>, а не кліком по самому <th> — інакше воно недоступне з клавіатури.

stickyHeader вимагає контейнера з власною прокруткою — тобто maxHeight або fill. Без жодного з них закріплювати шапку немає відносно чого, і властивість мовчки нічого не робить.

Дві осі версій збереженого

Найтонше місце всієї таблиці. Збережена розкладка має два незалежні номери версій, бо в ній змішані дві різні речі.

settingsVersionваш. Підніміть його, коли змінили дефолтні ширини чи видимість у headers: збережене більше не описує ту саму таблицю, і тримати його означало б показувати користувачам чужу розкладку.

SETTINGS_SCHEMAкомпонента. Коли міняється формат payload, порядок і видимість (це вибір користувача) мігрують уперед, а ширини й щільність (це дефолти компонента) скидаються.

Нова колонка вставляється після найближчого лівого сусіда, а не в кінець. Без цього колонка, додана через пів року, стрибала б у хвіст у кожного, хто колись міняв порядок.

tableId варто передавати динамічно там, де таблиця показує різні сутності (group-${id}) — компонент стежить і за ним, інакше наступна сутність відкрилася б із розкладкою попередньої.

Афорданси прокрутки

Переповнення рахується як ширина таблиці − ширина видимої області, а не через scrollWidth. Так градієнт з'являється і після ресайзу колонки — від нього розмір контейнера не міняється, тож самого ResizeObserver було б замало.

ResizeObserver при цьому теж потрібен: згортання сайдбара міняє ширину контейнера без жодної події resize вікна.

Чого тут немає

Віртуального скролу. Для списків у тисячі рядків беріть пагінацію або серверну фільтрацію. Якщо дані ще й ієрархічні — TreeTable: він віртуалізує рядки, але вимагає фіксованої висоти рядка, якої тут немає.