Table
Table строит нативную <table> из закрытого описания колонок и строк. По умолчанию ячейка выводит строку или число; cell snippet форматирует значение, не управляя структурой таблицы. Возможности тяжёлой таблицы Vue-легаси включаются опциями: сортировка по заголовку, замороженные колонки с тенью, липкая шапка, вложенные строки с шевроном, колонка чекбоксов, ширина колонок за рукоятку, строка итогов, скелетоны загрузки, локальный фильтр с подсветкой и копирование ячеек с Ctrl/Cmd.
Статус: experimental · в системе с 2026-07 · интерактивность: conditional (по конфигурации)
Когда использовать
- Нужно сравнить несколько однородных сущностей по одинаковым признакам.
- Числовые значения должны сохранять колонку и выравниваться по правому краю.
- Строкам нужен явный заголовок для чтения поперёк таблицы.
Не использовать:
- Данные образуют короткий последовательный список без сравнения по колонкам.
- Нужно дерево с произвольной глубиной или объединение ячеек — вложенные строки Table держат один тип строки на всех уровнях.
- Редактирование значений в ячейках — Table показывает и выбирает, но не правит.
Сравнение кампаний
Заголовок строки и числовые колонки сохраняют направление чтения.
| Кампания | Канал | Показы | Расход, ₽ |
|---|---|---|---|
| Брендовый поиск | Поиск | 184200 | 96400 |
| Возврат аудитории | Баннеры | 723900 | 121800 |
| Знакомство с продуктом | Видео | 342100 | 157300 |
<!--
🟢
Базовый пример Table сравнивает кампании по общим колонкам. Название
кампании служит заголовком строки, а числа выровнены по end.
-->
<script>
import { t } from "$lib/spec/demo-lang.svelte.js";
import Table from "$lib/ui/Table.svelte";
const columns = [
{ key: "name", label: t("Кампания"), rowHeader: true },
{ key: "channel", label: t("Канал") },
{ key: "impressions", label: t("Показы"), align: "end" },
{ key: "spend", label: t("Расход, ₽"), align: "end" },
];
const rows = [
{
id: "search",
name: t("Брендовый поиск"),
channel: t("Поиск"),
impressions: 184_200,
spend: 96_400,
},
{
id: "retargeting",
name: t("Возврат аудитории"),
channel: t("Баннеры"),
impressions: 723_900,
spend: 121_800,
},
{
id: "video",
name: t("Знакомство с продуктом"),
channel: t("Видео"),
impressions: 342_100,
spend: 157_300,
},
];
</script>
<Table {columns} {rows} caption={t("Кампании за июль")} />
Форматирование значений
Cell snippet добавляет единицы и формат числа внутри готовых ячеек.
| Сегмент | Конверсия | Выручка |
|---|---|---|
| Новые клиенты | 6,4 % | 842 000 ₽ |
| Вернувшиеся клиенты | 11,8 % | 1 436 000 ₽ |
<!--
🟢
Пример cell snippet форматирует долю и выручку внутри готовых ячеек.
Table по-прежнему управляет th, td и порядком колонок.
-->
<script>
import { t } from "$lib/spec/demo-lang.svelte.js";
import Table from "$lib/ui/Table.svelte";
const percent = new Intl.NumberFormat("ru-RU", {
style: "percent",
maximumFractionDigits: 1,
});
const money = new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
maximumFractionDigits: 0,
});
const columns = [
{ key: "segment", label: t("Сегмент"), rowHeader: true },
{ key: "conversion", label: t("Конверсия"), align: "end" },
{ key: "revenue", label: t("Выручка"), align: "end" },
];
const rows = [
{ id: "new", segment: t("Новые клиенты"), conversion: 0.064, revenue: 842_000 },
{
id: "returning",
segment: t("Вернувшиеся клиенты"),
conversion: 0.118,
revenue: 1_436_000,
},
];
</script>
{#snippet cell({ column, value })}
{#if column.key === "conversion"}
{percent.format(value)}
{:else if column.key === "revenue"}
<strong>{money.format(value)}</strong>
{:else}
{value}
{/if}
{/snippet}
<Table {columns} {rows} {cell} caption={t("Результат по сегментам")} />
Нет строк
EmptyText объясняет нормальное отсутствие данных без loading-состояния.
| Отчёт | Период | Сумма |
|---|---|---|
| Отчётов за период нет |
<!--
🟢
Empty-пример сохраняет заголовки доступных колонок без строк данных.
Сообщение называет отсутствующий контент и не имитирует загрузку.
-->
<script>
import { t } from "$lib/spec/demo-lang.svelte.js";
import Table from "$lib/ui/Table.svelte";
const columns = [
{ key: "report", label: t("Отчёт"), rowHeader: true },
{ key: "period", label: t("Период") },
{ key: "total", label: t("Сумма"), align: "end" },
];
const rows = [];
</script>
<Table
{columns}
{rows}
caption={t("Закрывающие документы")}
emptyText={t("Отчётов за период нет")}
/>
Сортировка
sortable: клик по заголовку сообщает onsort, приложение сортирует строки, стрелка разворачивается при убывании; подсказка в заголовке.
| Знакомство с продуктом | 3050 | 0.9 | 157300 |
|---|---|---|---|
| Возврат аудитории | 8120 | 1.1 | 121800 |
| Брендовый поиск | 12400 | 6.7 | 96400 |
| Партнёрская сеть | 15900 | 2.4 | 88000 |
<!--
🟢
Сортировка по заголовку (эталон: Vue-легаси blocks/table): клик по колонке
меняет порядок, стрелка разворачивается при убывании. Таблица только
сообщает `onsort` — строки сортирует приложение (здесь — сама демка).
-->
<script>
import { t } from "$lib/spec/demo-lang.svelte.js";
import Table from "$lib/ui/Table.svelte";
const columns = [
{ key: "name", label: t("Кампания"), rowHeader: true },
{ key: "clicks", label: t("Клики"), align: "end" },
{ key: "ctr", label: "CTR, %", align: "end", hint: t("Доля кликов от показов") },
{ key: "spend", label: t("Расход, ₽"), align: "end" },
];
const source = [
{ id: 1, name: t("Брендовый поиск"), clicks: 12_400, ctr: 6.7, spend: 96_400 },
{ id: 2, name: t("Возврат аудитории"), clicks: 8_120, ctr: 1.1, spend: 121_800 },
{ id: 3, name: t("Знакомство с продуктом"), clicks: 3_050, ctr: 0.9, spend: 157_300 },
{ id: 4, name: t("Партнёрская сеть"), clicks: 15_900, ctr: 2.4, spend: 88_000 },
];
let sort = $state({ key: "spend", desc: true });
let rows = $derived(
[...source].sort((a, b) => {
const direction = sort.desc ? -1 : 1;
const [x, y] = [a[sort.key], b[sort.key]];
return direction * (typeof x === "number" ? x - y : String(x).localeCompare(String(y)));
}),
);
</script>
<Table {columns} {rows} sortable bind:sort caption={t("Кампании за июль")} />
Замороженная колонка
fixed={1}, stickyHeader, resizable: название на месте при прокрутке, шапка липнет, ширина за рукоятку.
| Кампания | Показы | Клики | CTR, % | CPC, ₽ | Конверсии | CPA, ₽ | Расход, ₽ |
|---|---|---|---|---|---|---|---|
| Кампания 1 | 100000 | 1200 | 1.00 | 12 | 40 | 300 | 15000 |
| Кампания 2 | 112345 | 1297 | 1.30 | 13 | 47 | 311 | 16234 |
| Кампания 3 | 124690 | 1394 | 1.60 | 14 | 54 | 322 | 17468 |
| Кампания 4 | 137035 | 1491 | 1.90 | 15 | 61 | 333 | 18702 |
| Кампания 5 | 149380 | 1588 | 2.20 | 16 | 68 | 344 | 19936 |
| Кампания 6 | 161725 | 1685 | 2.50 | 17 | 75 | 355 | 21170 |
| Кампания 7 | 174070 | 1782 | 2.80 | 18 | 82 | 366 | 22404 |
| Кампания 8 | 186415 | 1879 | 3.10 | 19 | 89 | 377 | 23638 |
<!--
🟢
Замороженная первая колонка, липкая шапка и ресайз (эталон: Vue-легаси
table `fixed`, `stickyHeader`, drag-trigger): широкая таблица в узкой
области прокрутки — название остаётся на месте, справа затухание, за
рукоятку у правого края заголовка меняется ширина, двойной клик — по
содержимому.
-->
<script>
import { t } from "$lib/spec/demo-lang.svelte.js";
import Table from "$lib/ui/Table.svelte";
const columns = [
{ key: "name", label: t("Кампания"), rowHeader: true, width: 200 },
{ key: "impressions", label: t("Показы"), align: "end", width: 120 },
{ key: "clicks", label: t("Клики"), align: "end", width: 120 },
{ key: "ctr", label: "CTR, %", align: "end", width: 100 },
{ key: "cpc", label: "CPC, ₽", align: "end", width: 100 },
{ key: "conversions", label: t("Конверсии"), align: "end", width: 120 },
{ key: "cpa", label: "CPA, ₽", align: "end", width: 100 },
{ key: "spend", label: t("Расход, ₽"), align: "end", width: 120 },
];
const rows = Array.from({ length: 8 }, (_, i) => ({
id: i + 1,
name: `${t("Кампания")} ${i + 1}`,
impressions: 100_000 + i * 12_345,
clicks: 1_200 + i * 97,
ctr: (1 + i * 0.3).toFixed(2),
cpc: 12 + i,
conversions: 40 + i * 7,
cpa: 300 + i * 11,
spend: 15_000 + i * 1_234,
}));
let widths = $state({});
</script>
<div style="width: 560px; max-height: 280px; display: flex;">
<Table {columns} {rows} fixed={1} stickyHeader resizable bind:widths />
</div>
Вложенные строки и итоги
expandable с children и total: шеврон раскрывает детализацию, итоги под шапкой.
| Кампания | Показы | Клики | Расход, ₽ |
|---|---|---|---|
| Итого | 1250200 | 23570 | 375500 |
| Брендовый поиск | 184200 | 12400 | 96400 |
| 1 июля | 60100 | 4100 | 31000 |
| 2 июля | 62400 | 4200 | 32500 |
| 3 июля | 61700 | 4100 | 32900 |
| Возврат аудитории | 723900 | 8120 | 121800 |
| Знакомство с продуктом | 342100 | 3050 | 157300 |
<!--
🟢
Вложенные строки и итоги (эталон: Vue-легаси table `expand`, `total`):
шеврон в первой колонке раскрывает детализацию по дням с отступом уровня,
строка итогов стоит под шапкой; `onexpand` — момент для подгрузки детей.
-->
<script>
import { t } from "$lib/spec/demo-lang.svelte.js";
import Table from "$lib/ui/Table.svelte";
const columns = [
{ key: "name", label: t("Кампания"), rowHeader: true },
{ key: "impressions", label: t("Показы"), align: "end" },
{ key: "clicks", label: t("Клики"), align: "end" },
{ key: "spend", label: t("Расход, ₽"), align: "end" },
];
const rows = [
{
id: "search",
name: t("Брендовый поиск"),
impressions: 184_200,
clicks: 12_400,
spend: 96_400,
children: [
{ id: "search-1", name: t("1 июля"), impressions: 60_100, clicks: 4_100, spend: 31_000 },
{ id: "search-2", name: t("2 июля"), impressions: 62_400, clicks: 4_200, spend: 32_500 },
{ id: "search-3", name: t("3 июля"), impressions: 61_700, clicks: 4_100, spend: 32_900 },
],
},
{
id: "retargeting",
name: t("Возврат аудитории"),
impressions: 723_900,
clicks: 8_120,
spend: 121_800,
children: [{ id: "retargeting-1", name: t("1 июля"), impressions: 723_900, clicks: 8_120, spend: 121_800 }],
},
{ id: "video", name: t("Знакомство с продуктом"), impressions: 342_100, clicks: 3_050, spend: 157_300 },
];
const total = { impressions: 1_250_200, clicks: 23_570, spend: 375_500 };
let expanded = $state(["search"]);
</script>
<Table {columns} {rows} {total} expandable bind:expanded caption={t("Кампании по дням")} />
Выбор строк
selectable: чекбокс шапки выбирает все видимые, часть — indeterminate.
| Кампания | Статус | Расход, ₽ | |
|---|---|---|---|
| Брендовый поиск | Активна | 96400 | |
| Возврат аудитории | Пауза | 121800 | |
| Знакомство с продуктом | Активна | 157300 |
<!--
🟢
Колонка чекбоксов (эталон: Vue-легаси table `checkbox`): чекбокс в шапке
выбирает все видимые строки, при частичном выборе — состояние
indeterminate; выбранные id идут в панель массовых действий.
-->
<script>
import { t } from "$lib/spec/demo-lang.svelte.js";
import Table from "$lib/ui/Table.svelte";
const columns = [
{ key: "name", label: t("Кампания"), rowHeader: true },
{ key: "status", label: t("Статус") },
{ key: "spend", label: t("Расход, ₽"), align: "end" },
];
const rows = [
{ id: 1, name: t("Брендовый поиск"), status: t("Активна"), spend: 96_400 },
{ id: 2, name: t("Возврат аудитории"), status: t("Пауза"), spend: 121_800 },
{ id: 3, name: t("Знакомство с продуктом"), status: t("Активна"), spend: 157_300 },
];
let selected = $state([2]);
</script>
<div style="display: flex; flex-direction: column; gap: var(--sp-2);">
<Table {columns} {rows} selectable bind:selected />
<span aria-live="polite">{t("Выбрано:")} {selected.length ? selected.join(", ") : t("ничего")}</span>
</div>
Загрузка
loading без строк — скелетоны-заглушки; row.loading — скелетоны в строке.
| Кампания | Показы | Расход, ₽ |
|---|---|---|
| Кампания | Показы | Расход, ₽ |
|---|---|---|
| Брендовый поиск | 184200 | 96400 |
<!--
🟢
Загрузка (эталон: Vue-легаси `options.loading` + v-skeleton): пока данных
нет, таблица рисует пять строк скелетонов; при обновлении уже полученных
строк скелетоны занимают место значений.
-->
<script>
import { t } from "$lib/spec/demo-lang.svelte.js";
import Table from "$lib/ui/Table.svelte";
const columns = [
{ key: "name", label: t("Кампания"), rowHeader: true },
{ key: "impressions", label: t("Показы"), align: "end" },
{ key: "spend", label: t("Расход, ₽"), align: "end" },
];
</script>
<div style="display: flex; flex-direction: column; gap: var(--sp-3);">
<Table {columns} rows={[]} loading skeletonRows={3} caption={t("Первая загрузка")} />
<Table
{columns}
rows={[
{ id: 1, name: t("Брендовый поиск"), impressions: 184_200, spend: 96_400 },
{ id: 2, name: t("Возврат аудитории"), impressions: 723_900, spend: 121_800, loading: true },
]}
caption={t("Строка обновляется")}
/>
</div>
Плотность и плоский вид
size="s" — строка 32 (ADMY 30) на мелком кегле; flat — без фона шапки и делителей.
| Кампания | Клики | Расход, ₽ |
|---|---|---|
| Брендовый поиск | 12400 | 96400 |
| Возврат аудитории | 8120 | 121800 |
| Знакомство с продуктом | 3050 | 157300 |
| Кампания | Клики | Расход, ₽ |
|---|---|---|
| Брендовый поиск | 12400 | 96400 |
| Возврат аудитории | 8120 | 121800 |
| Знакомство с продуктом | 3050 | 157300 |
<!--
🟢
Плотность и плоский вид (эталон: Vue-легаси table `size` и `flat`):
`size="s"` — строка 30 на 13px для плотных отчётов, `flat` — без фона
шапки и штриховых делителей, только тонкие линии строк.
-->
<script>
import { t } from "$lib/spec/demo-lang.svelte.js";
import Table from "$lib/ui/Table.svelte";
const columns = [
{ key: "name", label: t("Кампания"), rowHeader: true },
{ key: "clicks", label: t("Клики"), align: "end" },
{ key: "spend", label: t("Расход, ₽"), align: "end" },
];
const rows = [
{ id: 1, name: t("Брендовый поиск"), clicks: 12_400, spend: 96_400 },
{ id: 2, name: t("Возврат аудитории"), clicks: 8_120, spend: 121_800 },
{ id: 3, name: t("Знакомство с продуктом"), clicks: 3_050, spend: 157_300 },
];
</script>
<div style="display: flex; flex-direction: column; gap: var(--sp-3);">
<Table {columns} {rows} size="s" caption={t("Плотная")} />
<Table {columns} {rows} flat caption={t("Плоская")} />
</div>