# Table

## Кратко

`Table` — [составной компонент](https://vega-ui.dev/ru/docs/glossary/#составной-компонент-compound-component) для семантических табличных данных, когда нативная структура таблицы важнее взаимодействия с клавиатуры в стиле grid.

## Импорт

```tsx
import {
  Table,
  TableHead,
  TableBody,
  TableRow,
  TableHeading,
  TableData,
  TableFoot,
  type TableProps,
} from '@vega-ui/react';
```

## Экспортируемые типы

- `TableProps`
- `TableHeadProps`
- `TableBodyProps`
- `TableRowProps`
- `TableHeadingProps`
- `TableDataProps`
- `TableFootProps`

## Минимальная композиция

```tsx
import {
  Table,
  TableBody,
  TableData,
  TableHead,
  TableHeading,
  TableRow,
} from '@vega-ui/react';

export default function MinimalComposition() {
  return (
    <Table>
      <TableHead>
        <TableRow>
          <TableHeading scope='col'>Plan</TableHeading>
          <TableHeading scope='col'>Price</TableHeading>
        </TableRow>
      </TableHead>
      <TableBody>
        <TableRow>
          <TableData>Starter</TableData>
          <TableData>$9 / month</TableData>
        </TableRow>
        <TableRow>
          <TableData>Team</TableData>
          <TableData>$29 / month</TableData>
        </TableRow>
      </TableBody>
    </Table>
  );
}
```

## Обязательные части

- `Table`: корневая семантическая таблица и владелец выравнивания
- `TableRow`: структура строки для секций
- `TableHeading` или `TableData`: содержимое на уровне ячеек

## Необязательные части

- `TableHead`: секция заголовка для подписей столбцов
- `TableBody`: секция тела для строк данных
- `TableFoot`: секция подвала для сводок и итогов

## Порядок композиции

1. `Table`
2. `TableHead`
3. `TableBody`
4. `TableFoot`
5. `TableRow`
6. `TableHeading`
7. `TableData`

## Варианты

- Выравнивание данных через `dataAlign`
- Варианты раскладки `fullWidth` и `fullHeight`
- Края с отступами или вровень через `edgePadded`

## Пограничные случаи

- `Table` плохо подходит, когда интерфейсу нужны навигация с клавиатуры по ячейкам или сложные модели выбора.
- На маленьких экранах потребителю нужно явно обработать переполнение или перестроить раскладку в столбик.
- Выравнивание должно быть согласованным в ячейках заголовков и данных.

## Типичные ошибки

- Выбор `DataGrid`, когда достаточно семантической разметки таблицы.
- Отказ от семантических секций вроде `TableHead` только ради упрощения стилей.
- Игнорирование переполнения на маленьких экранах.

## Примеры

### Базовый

#### Базовый: сравнение тарифов с заголовками строк

Подпись (`caption`) называет таблицу, заголовки столбцов получают `scope='col'`, а первая ячейка каждой строки — это `TableHeading` со `scope='row'`, поэтому скринридер зачитывает каждое значение как «Single sign-on, Team». Иконки галочки и прочерка скрыты и дополнены визуально скрытым текстом «Included» / «Not included».

```tsx
import { Check, Minus } from '@vega-ui/icons';
import {
  Icon,
  Table,
  TableBody,
  TableData,
  TableHead,
  TableHeading,
  TableRow,
  VisuallyHidden,
} from '@vega-ui/react';

const plans = ['Starter', 'Team', 'Enterprise'];

const features: { name: string, values: (string | boolean)[] }[] = [
  { name: 'Price per seat', values: ['$9', '$29', 'Custom'] },
  { name: 'Projects', values: ['5', 'Unlimited', 'Unlimited'] },
  { name: 'Storage', values: ['10 GB', '1 TB', '5 TB'] },
  { name: 'Single sign-on', values: [false, true, true] },
  { name: 'Audit log', values: [false, false, true] },
];

export const layout = 'wide';

export default function PlanComparisonTable() {
  return (
    <div role='region' aria-label='Compare plans' tabIndex={0} style={{ width: '100%', overflowX: 'auto', border: '1px solid var(--separator-opaque)', borderRadius: 'var(--radius-4)', background: 'var(--surface-primary)' }}>
      <Table fullWidth edgePadded dataAlign='center' style={{ borderStyle: 'hidden' }}>
        <caption style={{ captionSide: 'top', textAlign: 'start', padding: 'var(--spacing-6)', paddingBlockEnd: 0, fontWeight: 600 }}>
          Compare plans
        </caption>
        <TableHead>
          <TableRow>
            <TableHeading scope='col' dataAlign='start'>Feature</TableHeading>
            {plans.map((plan) => <TableHeading key={plan} scope='col'>{plan}</TableHeading>)}
          </TableRow>
        </TableHead>
        <TableBody>
          {features.map((feature) => (
            <TableRow key={feature.name}>
              <TableHeading scope='row' dataAlign='start' style={{ fontWeight: 400, whiteSpace: 'nowrap' }}>{feature.name}</TableHeading>
              {feature.values.map((value, index) => {
                if (typeof value === 'string') {
                  return <TableData key={plans[index]}>{value}</TableData>;
                }

                return (
                  <TableData key={plans[index]}>
                    <Icon size='sm' aria-hidden='true'>{value ? <Check /> : <Minus />}</Icon>
                    <VisuallyHidden>{value ? 'Included' : 'Not included'}</VisuallyHidden>
                  </TableData>
                );
              })}
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  );
}
```

#### Базовый: позиции счёта с вычисленными итогами

Промежуточный итог, налог и сумма к оплате вычисляются из позиций и попадают в `TableFoot`. Подписи — заголовки строк на два столбца, поэтому каждый итог остаётся связан со своей подписью.

```tsx
import {
  Table,
  TableBody,
  TableData,
  TableFoot,
  TableHead,
  TableHeading,
  TableRow,
} from '@vega-ui/react';

const lines = [
  { item: 'Team plan · 12 seats', quantity: 12, unitPrice: 29 },
  { item: 'Extra storage · 500 GB', quantity: 1, unitPrice: 40 },
  { item: 'Priority support', quantity: 1, unitPrice: 99 },
];

const currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });

export default function SummaryTableWithFooterTotals() {
  const subtotal = lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0);
  const tax = subtotal * 0.2;

  return (
    <div style={{ width: '100%', border: '1px solid var(--separator-opaque)', borderRadius: 'var(--radius-4)', background: 'var(--surface-primary)' }}>
      <Table fullWidth edgePadded style={{ borderStyle: 'hidden' }}>
        <TableHead>
          <TableRow>
            <TableHeading scope='col'>Item</TableHeading>
            <TableHeading scope='col'>Qty</TableHeading>
            <TableHeading scope='col'>Amount</TableHeading>
          </TableRow>
        </TableHead>
        <TableBody>
          {lines.map((line) => (
            <TableRow key={line.item}>
              <TableData>{line.item}</TableData>
              <TableData>{line.quantity}</TableData>
              <TableData style={{ fontVariantNumeric: 'tabular-nums' }}>{currency.format(line.quantity * line.unitPrice)}</TableData>
            </TableRow>
          ))}
        </TableBody>
        <TableFoot>
          <TableRow>
            <TableHeading scope='row' colSpan={2} dataAlign='start' style={{ fontWeight: 400 }}>Subtotal</TableHeading>
            <TableData style={{ fontVariantNumeric: 'tabular-nums' }}>{currency.format(subtotal)}</TableData>
          </TableRow>
          <TableRow>
            <TableHeading scope='row' colSpan={2} dataAlign='start' style={{ fontWeight: 400 }}>VAT 20%</TableHeading>
            <TableData style={{ fontVariantNumeric: 'tabular-nums' }}>{currency.format(tax)}</TableData>
          </TableRow>
          <TableRow>
            <TableHeading scope='row' colSpan={2} dataAlign='start'>Total due</TableHeading>
            <TableData style={{ fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{currency.format(subtotal + tax)}</TableData>
          </TableRow>
        </TableFoot>
      </Table>
    </div>
  );
}
```

### Контролируемый/с состоянием

#### Контролируемый/с состоянием: сортировка по столбцам

Столбец и направление сортировки хранятся в состоянии; в каждом сортируемом заголовке настоящая кнопка, а текущий порядок передаётся через `aria-sort`. Суммы сортируются как числа, даты — по ISO-значению, а в ячейках показан отформатированный текст.

```tsx
import { ArrowDown, ArrowUp, ArrowUpDown } from '@vega-ui/icons';
import {
  Button,
  Icon,
  Table,
  TableBody,
  TableData,
  TableHead,
  TableHeading,
  TableRow,
} from '@vega-ui/react';
import { useState } from 'react';

type Invoice = { id: string, customer: string, issued: string, amount: number };
type SortKey = 'customer' | 'issued' | 'amount';
type Direction = 'ascending' | 'descending';

const invoices: Invoice[] = [
  { id: 'INV-1042', customer: 'Northwind Traders', issued: '2026-10-02', amount: 1240 },
  { id: 'INV-1043', customer: 'Acme Design', issued: '2026-09-18', amount: 380 },
  { id: 'INV-1044', customer: 'Blue Harbor Cafe', issued: '2026-10-21', amount: 96.5 },
  { id: 'INV-1045', customer: 'Lumen Labs', issued: '2026-08-30', amount: 4210 },
];

const columns: { key: SortKey, label: string }[] = [
  { key: 'customer', label: 'Customer' },
  { key: 'issued', label: 'Issued' },
  { key: 'amount', label: 'Amount' },
];

const currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
const date = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeZone: 'UTC' });

export const layout = 'wide';

export default function SortableColumns() {
  const [sortKey, setSortKey] = useState<SortKey>('issued');
  const [direction, setDirection] = useState<Direction>('descending');

  const sorted = [...invoices].sort((a, b) => {
    let result = 0;
    if (sortKey === 'amount') {
      result = a.amount - b.amount;
    } else {
      result = a[sortKey].localeCompare(b[sortKey]);
    }
    return direction === 'ascending' ? result : -result;
  });

  const sortBy = (key: SortKey) => {
    if (key === sortKey) {
      setDirection(direction === 'ascending' ? 'descending' : 'ascending');
      return;
    }
    setSortKey(key);
    setDirection('ascending');
  };

  return (
    <div role='region' aria-label='Invoices' tabIndex={0} style={{ width: '100%', overflowX: 'auto', border: '1px solid var(--separator-opaque)', borderRadius: 'var(--radius-4)', background: 'var(--surface-primary)' }}>
      <Table fullWidth edgePadded style={{ borderStyle: 'hidden' }}>
        <TableHead>
          <TableRow>
            <TableHeading scope='col'>Invoice</TableHeading>
            {columns.map((column) => {
              const active = column.key === sortKey;
              let icon = <ArrowUpDown />;
              if (active) {
                icon = direction === 'ascending' ? <ArrowUp /> : <ArrowDown />;
              }

              return (
                <TableHeading key={column.key} scope='col' aria-sort={active ? direction : undefined}>
                  <Button size='sm' variant='secondary' appearance='transparent' onClick={() => sortBy(column.key)}>
                    {column.label}
                    <Icon size='xs' aria-hidden='true'>{icon}</Icon>
                  </Button>
                </TableHeading>
              );
            })}
          </TableRow>
        </TableHead>
        <TableBody>
          {sorted.map((invoice) => (
            <TableRow key={invoice.id}>
              <TableData style={{ whiteSpace: 'nowrap' }}>{invoice.id}</TableData>
              <TableData style={{ whiteSpace: 'nowrap' }}>{invoice.customer}</TableData>
              <TableData style={{ whiteSpace: 'nowrap' }}>{date.format(new Date(invoice.issued))}</TableData>
              <TableData style={{ whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }}>{currency.format(invoice.amount)}</TableData>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  );
}
```

### Форма/интеграция

#### Форма/интеграция: выбор строк для массового действия

В каждой строке именованный чекбокс (`name='member'`, `value` — id строки), а чекбокс в заголовке выбирает все строки или показывает неопределённое состояние при частичном выборе. При отправке обработчик собирает выбранные id через `FormData.getAll`.

```tsx
import {
  Button,
  Checkbox,
  CheckboxCheckedIcon,
  CheckboxIndeterminateIcon,
  CheckboxIndicator,
  CheckboxInput,
  HelperText,
  Table,
  TableBody,
  TableData,
  TableHead,
  TableHeading,
  TableRow,
} from '@vega-ui/react';
import { FormEvent, useState } from 'react';

const members = [
  { id: 'u-17', name: 'Maya Chen', email: 'maya@acme.io' },
  { id: 'u-23', name: 'Omar Haddad', email: 'omar@acme.io' },
  { id: 'u-31', name: 'Lena Fischer', email: 'lena@acme.io' },
  { id: 'u-42', name: 'Tomás Ruiz', email: 'tomas@acme.io' },
];

export default function RowSelectionForm() {
  const [selected, setSelected] = useState<string[]>([]);
  const [message, setMessage] = useState('');

  const allSelected = selected.length === members.length;
  const someSelected = selected.length > 0 && !allSelected;

  const toggle = (id: string) => {
    if (selected.includes(id)) {
      setSelected(selected.filter((item) => item !== id));
      return;
    }
    setSelected([...selected, id]);
  };

  const toggleAll = () => {
    setSelected(allSelected ? [] : members.map((member) => member.id));
  };

  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const ids = new FormData(event.currentTarget).getAll('member');
    setMessage(`Sent a reminder to ${ids.length} ${ids.length === 1 ? 'member' : 'members'}: ${ids.join(', ')}.`);
  };

  return (
    <form onSubmit={handleSubmit} style={{ display: 'grid', gap: 'var(--spacing-8)', width: '100%' }}>
      <div role='region' aria-label='Members' tabIndex={0} style={{ width: '100%', overflowX: 'auto', border: '1px solid var(--separator-opaque)', borderRadius: 'var(--radius-4)', background: 'var(--surface-primary)' }}>
        <Table fullWidth edgePadded dataAlign='start' style={{ borderStyle: 'hidden' }}>
          <TableHead>
            <TableRow>
              <TableHeading scope='col' style={{ width: 40 }}>
                <Checkbox>
                  <CheckboxInput aria-label='Select all members' checked={allSelected} indeterminate={someSelected} onChange={toggleAll} />
                  <CheckboxIndicator>
                    <CheckboxCheckedIcon />
                    <CheckboxIndeterminateIcon />
                  </CheckboxIndicator>
                </Checkbox>
              </TableHeading>
              <TableHeading scope='col'>Name</TableHeading>
              <TableHeading scope='col'>Email</TableHeading>
            </TableRow>
          </TableHead>
          <TableBody>
            {members.map((member) => (
              <TableRow key={member.id}>
                <TableData>
                  <Checkbox>
                    <CheckboxInput
                      name='member'
                      value={member.id}
                      aria-label={`Select ${member.name}`}
                      checked={selected.includes(member.id)}
                      onChange={() => toggle(member.id)}
                    />
                    <CheckboxIndicator>
                      <CheckboxCheckedIcon />
                    </CheckboxIndicator>
                  </Checkbox>
                </TableData>
                <TableData style={{ whiteSpace: 'nowrap' }}>{member.name}</TableData>
                <TableData style={{ whiteSpace: 'nowrap' }}>{member.email}</TableData>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-8)' }}>
        <Button type='submit' size='sm' disabled={selected.length === 0} style={{ flexShrink: 0 }}>Send reminder</Button>
        <HelperText role='status'>{message || `${selected.length} selected`}</HelperText>
      </div>
    </form>
  );
}
```

### Раскладка/оверлей

#### Раскладка/оверлей: прокручиваемая таблица в диалоге

Длинный список начислений внутри диалога прокручивается в собственной фокусируемой области и не растягивает диалог. Ячейки заголовка липкие и окрашены в цвет поверхности диалога, поэтому строки под ними не просвечивают.

```tsx
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  Icon,
  IconButton,
  Table,
  TableBody,
  TableData,
  TableHead,
  TableHeading,
  TableRow,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';
import { CSSProperties, useId } from 'react';

const charges = Array.from({ length: 18 }, (_, index) => ({
  date: `Oct ${index + 1}`,
  description: index % 3 === 0 ? 'Build minutes overage' : 'API requests',
  amount: 4 + ((index * 7) % 23),
}));

export default function BillingTableInsideADialog() {
  const titleId = useId();

  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant='secondary' appearance='ghost'>View October charges</Button>
      </DialogTrigger>
      <DialogContent aria-labelledby={titleId} style={{ display: 'grid', gap: 'var(--spacing-8)' }}>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
          <Heading id={titleId} as='h2' size={4} fontWeight={600}>October usage charges</Heading>
          <DialogClose asChild>
            <IconButton
              aria-label='Close'
              variant='secondary'
              appearance='ghost'
              style={{ flexShrink: 0, '--icon-button-size': '24px', '--icon-button-br-ratio': 0.5 } as CSSProperties}
            >
              <Icon size='xs'><XIcon strokeWidth={2.5} /></Icon>
            </IconButton>
          </DialogClose>
        </div>
        <div role='region' aria-labelledby={titleId} tabIndex={0} style={{ maxHeight: 280, overflowY: 'auto' }}>
          <Table fullWidth>
            <TableHead>
              <TableRow>
                <TableHeading scope='col' style={{ position: 'sticky', insetBlockStart: 0, background: 'var(--surface-primary)' }}>Date</TableHeading>
                <TableHeading scope='col' style={{ position: 'sticky', insetBlockStart: 0, background: 'var(--surface-primary)' }}>Description</TableHeading>
                <TableHeading scope='col' style={{ position: 'sticky', insetBlockStart: 0, background: 'var(--surface-primary)' }}>Amount</TableHeading>
              </TableRow>
            </TableHead>
            <TableBody>
              {charges.map((charge) => (
                <TableRow key={charge.date}>
                  <TableData>{charge.date}</TableData>
                  <TableData>{charge.description}</TableData>
                  <TableData style={{ fontVariantNumeric: 'tabular-nums' }}>${charge.amount}.00</TableData>
                </TableRow>
              ))}
            </TableBody>
          </Table>
        </div>
      </DialogContent>
    </Dialog>
  );
}
```

### Ошибка

#### Ошибка: строки не загрузились

Состояния загрузки, ошибки и данных живут в одной таблице: строки загрузки и ошибки растянуты на все столбцы через `colSpan={columns.length}`. Ошибка объявляется через `role='alert'`, а Retry загружает строки со второй попытки.

```tsx
import {
  Button,
  HelperText,
  Spinner,
  Table,
  TableBody,
  TableData,
  TableHead,
  TableHeading,
  TableRow,
} from '@vega-ui/react';
import { useEffect, useState } from 'react';

type Status = 'loading' | 'error' | 'ready';

const columns = ['Deploy', 'Branch', 'Duration'];

const deploys = [
  { id: '#4182', branch: 'main', duration: '3m 12s' },
  { id: '#4181', branch: 'fix/invoice-rounding', duration: '2m 47s' },
  { id: '#4180', branch: 'main', duration: '3m 05s' },
];

export default function FailedToLoadRows() {
  const [status, setStatus] = useState<Status>('loading');
  const [attempt, setAttempt] = useState(1);

  useEffect(() => {
    if (status !== 'loading') return;

    const timer = setTimeout(() => setStatus(attempt === 1 ? 'error' : 'ready'), 1000);

    return () => clearTimeout(timer);
  }, [status, attempt]);

  const retry = () => {
    setAttempt(attempt + 1);
    setStatus('loading');
  };

  return (
    <div style={{ width: '100%', border: '1px solid var(--separator-opaque)', borderRadius: 'var(--radius-4)', background: 'var(--surface-primary)' }}>
      <Table fullWidth edgePadded aria-busy={status === 'loading'} style={{ borderStyle: 'hidden' }}>
        <TableHead>
          <TableRow>
            {columns.map((column) => <TableHeading key={column} scope='col'>{column}</TableHeading>)}
          </TableRow>
        </TableHead>
        <TableBody>
          {status === 'loading' && (
            <TableRow>
              <TableData colSpan={columns.length} dataAlign='center' style={{ height: 'var(--spacing-52)' }}>
                <Spinner size={3} aria-label='Loading deploys' style={{ marginInline: 'auto' }} />
              </TableData>
            </TableRow>
          )}
          {status === 'error' && (
            <TableRow>
              <TableData colSpan={columns.length} dataAlign='center' style={{ height: 'var(--spacing-52)' }}>
                <div style={{ display: 'grid', gap: 'var(--spacing-6)', justifyItems: 'center' }}>
                  <HelperText error role='alert'>Could not load deploys: the request timed out.</HelperText>
                  <Button size='sm' variant='secondary' appearance='ghost' onClick={retry}>Retry</Button>
                </div>
              </TableData>
            </TableRow>
          )}
          {status === 'ready' && deploys.map((deploy) => (
            <TableRow key={deploy.id}>
              <TableData>{deploy.id}</TableData>
              <TableData>{deploy.branch}</TableData>
              <TableData>{deploy.duration}</TableData>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  );
}
```

### Отключённый

#### Отключённый: архивные строки с заблокированными действиями

Архивные строки получают вторичный цвет текста и бейдж «Archived», так что состояние передаётся не только цветом, а действия в них отключены. У каждой кнопки действия доступное имя включает название проекта.

```tsx
import {
  Badge,
  Button,
  Table,
  TableBody,
  TableData,
  TableHead,
  TableHeading,
  TableRow,
} from '@vega-ui/react';

const projects = [
  { id: 'p1', name: 'Mobile app redesign', owner: 'Maya Chen', archived: false },
  { id: 'p2', name: 'Spring campaign 2025', owner: 'Omar Haddad', archived: true },
  { id: 'p3', name: 'Billing migration', owner: 'Lena Fischer', archived: false },
  { id: 'p4', name: 'Legacy API docs', owner: 'Tomás Ruiz', archived: true },
];

export const layout = 'wide';

export default function VisuallySubduedArchivedRows() {
  return (
    <div role='region' aria-label='Projects' tabIndex={0} style={{ width: '100%', overflowX: 'auto', border: '1px solid var(--separator-opaque)', borderRadius: 'var(--radius-4)', background: 'var(--surface-primary)' }}>
      <Table fullWidth edgePadded style={{ borderStyle: 'hidden' }}>
        <TableHead>
          <TableRow>
            <TableHeading scope='col'>Project</TableHeading>
            <TableHeading scope='col'>Owner</TableHeading>
            <TableHeading scope='col'>Actions</TableHeading>
          </TableRow>
        </TableHead>
        <TableBody>
          {projects.map((project) => (
            <TableRow key={project.id} style={project.archived ? { color: 'var(--label-secondary)' } : undefined}>
              <TableData>
                <span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--spacing-4)' }}>
                  {project.name}
                  {project.archived && <Badge size='sm' appearance='surface'>Archived</Badge>}
                </span>
              </TableData>
              <TableData style={{ whiteSpace: 'nowrap' }}>{project.owner}</TableData>
              <TableData>
                <Button size='sm' variant='secondary' appearance='ghost' disabled={project.archived} aria-label={`Edit ${project.name}`}>
                  Edit
                </Button>
              </TableData>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  );
}
```

### Пограничный случай

#### Пограничный случай: пустой результат после фильтрации

Если поиск ничего не нашёл, в теле таблицы остаётся одна строка на все столбцы с пояснением и выходом из ситуации. Заголовки остаются на месте, и таблица не схлопывается.

```tsx
import {
  Button,
  Label,
  Table,
  TableBody,
  TableData,
  TableHead,
  TableHeading,
  TableRow,
  Text,
  TextField,
  TextFieldInput,
} from '@vega-ui/react';
import { useId, useState } from 'react';

const columns = ['Customer', 'Country', 'Orders'];

const customers = [
  { name: 'Northwind Traders', country: 'Germany', orders: 42 },
  { name: 'Blue Harbor Cafe', country: 'Portugal', orders: 7 },
  { name: 'Lumen Labs', country: 'Canada', orders: 19 },
];

export default function EmptyStateRowWhenDataIsUnavailable() {
  const fieldId = useId();
  const [query, setQuery] = useState('zz');

  const matches = customers.filter((customer) => customer.name.toLowerCase().includes(query.trim().toLowerCase()));

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-12)', width: '100%' }}>
      <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
        <Label htmlFor={fieldId}>Search customers</Label>
        <TextField>
          <TextFieldInput id={fieldId} type='search' value={query} onChange={(event) => setQuery(event.target.value)} />
        </TextField>
      </div>
      <div style={{ width: '100%', border: '1px solid var(--separator-opaque)', borderRadius: 'var(--radius-4)', background: 'var(--surface-primary)' }}>
        <Table fullWidth edgePadded style={{ borderStyle: 'hidden' }}>
          <TableHead>
            <TableRow>
              {columns.map((column) => <TableHeading key={column} scope='col'>{column}</TableHeading>)}
            </TableRow>
          </TableHead>
          <TableBody>
            {matches.length === 0 && (
              <TableRow>
                <TableData colSpan={columns.length} dataAlign='center'>
                  <div style={{ display: 'grid', gap: 'var(--spacing-6)', justifyItems: 'center', paddingBlock: 'var(--spacing-8)' }}>
                    <Text size={2} style={{ color: 'var(--label-secondary)' }}>No customers match “{query}”.</Text>
                    <Button size='sm' variant='secondary' appearance='ghost' onClick={() => setQuery('')}>Clear search</Button>
                  </div>
                </TableData>
              </TableRow>
            )}
            {matches.map((customer) => (
              <TableRow key={customer.name}>
                <TableData>{customer.name}</TableData>
                <TableData>{customer.country}</TableData>
                <TableData style={{ fontVariantNumeric: 'tabular-nums' }}>{customer.orders}</TableData>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </div>
    </div>
  );
}
```

#### Пограничный случай: 200 строк и 8 столбцов в контейнере прокрутки

Таблица сохраняет естественную ширину и прокручивается в обе стороны внутри `role='region'` с `tabIndex={0}`, поэтому прокрутить её можно и с клавиатуры. Строка заголовка прилипает к верху при прокрутке.

```tsx
import {
  Table,
  TableBody,
  TableData,
  TableHead,
  TableHeading,
  TableRow,
  Text,
} from '@vega-ui/react';
import { CSSProperties, useId } from 'react';

const columns = ['Order', 'Customer', 'City', 'Items', 'Subtotal', 'Shipping', 'Status', 'Placed'];
const cities = ['Lisbon', 'Berlin', 'Toronto', 'Osaka', 'Austin'];
const statuses = ['Paid', 'Shipped', 'Delivered', 'Refunded'];

const rows = Array.from({ length: 200 }, (_, index) => [
  `#${20000 + index}`,
  `Customer ${index + 1}`,
  cities[index % cities.length],
  String(1 + (index % 6)),
  `$${(19 + ((index * 13) % 300)).toFixed(2)}`,
  index % 4 === 0 ? 'Free' : '$4.90',
  statuses[index % statuses.length],
  `Oct ${1 + (index % 31)}, 2026`,
]);

const stickyHeading: CSSProperties = {
  position: 'sticky',
  insetBlockStart: 0,
  background: 'var(--surface-primary)',
  whiteSpace: 'nowrap',
};

export default function ScrollableWideTable() {
  const captionId = useId();

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-3)', width: '100%' }}>
      <Text id={captionId} size={2} fontWeight={500}>All orders ({rows.length})</Text>
      <div role='region' aria-labelledby={captionId} tabIndex={0} style={{ maxHeight: 320, overflow: 'auto', border: '1px solid var(--separator-opaque)', borderRadius: 'var(--radius-4)', background: 'var(--surface-primary)' }}>
        <Table edgePadded style={{ borderStyle: 'hidden' }}>
          <TableHead>
            <TableRow>
              {columns.map((column) => <TableHeading key={column} scope='col' style={stickyHeading}>{column}</TableHeading>)}
            </TableRow>
          </TableHead>
          <TableBody>
            {rows.map((row) => (
              <TableRow key={row[0]}>
                {row.map((cell, index) => <TableData key={columns[index]} style={{ whiteSpace: 'nowrap' }}>{cell}</TableData>)}
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </div>
    </div>
  );
}
```

#### Пограничный случай: длинный неразрывный текст

С `table-layout: fixed` и ширинами столбцов в `colgroup` длинное имя файла переносится в любом месте (`overflow-wrap: anywhere`), а email обрезается многоточием и хранит полное значение в `title`. Узкие столбцы при этом не сжимаются.

```tsx
import {
  Table,
  TableBody,
  TableData,
  TableHead,
  TableHeading,
  TableRow,
} from '@vega-ui/react';

const files = [
  { name: 'q3-board-presentation-final-v7-reviewed-by-legal-and-finance-DO-NOT-SHARE.pdf', owner: 'maya.chen@northwind-traders-international.example.com', size: '18.4 MB' },
  { name: 'logo.svg', owner: 'li@acme.io', size: '4 KB' },
  { name: 'Отчёт_о_продажах_за_третий_квартал_по_регионам.xlsx', owner: 'ivan.petrov@example.ru', size: '2.1 MB' },
];

export const layout = 'wide';

export default function LongCellContent() {
  return (
    <div style={{ width: '100%', border: '1px solid var(--separator-opaque)', borderRadius: 'var(--radius-4)', background: 'var(--surface-primary)' }}>
      <Table fullWidth edgePadded style={{ tableLayout: 'fixed', borderStyle: 'hidden' }}>
        <colgroup>
          <col />
          <col style={{ width: '35%' }} />
          <col style={{ width: 88 }} />
        </colgroup>
        <TableHead>
          <TableRow>
            <TableHeading scope='col'>File</TableHeading>
            <TableHeading scope='col'>Owner</TableHeading>
            <TableHeading scope='col'>Size</TableHeading>
          </TableRow>
        </TableHead>
        <TableBody>
          {files.map((file) => (
            <TableRow key={file.name}>
              <TableData style={{ overflowWrap: 'anywhere' }}>{file.name}</TableData>
              <TableData title={file.owner} style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                {file.owner}
              </TableData>
              <TableData style={{ whiteSpace: 'nowrap' }}>{file.size}</TableData>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  );
}
```

#### Пограничный случай: ячейки вровень с краями карточки

По умолчанию `edgePadded` равен `false`: первая и последняя ячейки теряют внешний отступ, и текст выравнивается по заголовку карточки. Переключите свойство, чтобы сравнить с отступами по краям.

```tsx
import {
  Button,
  Card,
  Heading,
  Table,
  TableBody,
  TableData,
  TableRow,
} from '@vega-ui/react';
import { useState } from 'react';

const details = [
  ['Plan', 'Team'],
  ['Billing cycle', 'Yearly'],
  ['Next invoice', '1 December 2026'],
];

export default function FlushEdgesWithoutExtraPadding() {
  const [edgePadded, setEdgePadded] = useState(false);

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-8)', justifyItems: 'start', width: '100%' }}>
      <Button size='sm' variant='secondary' appearance='ghost' aria-pressed={edgePadded} onClick={() => setEdgePadded(!edgePadded)}>
        edgePadded: {String(edgePadded)}
      </Button>
      <Card appearance='outline' size='sm' style={{ alignItems: 'stretch', width: '100%', maxWidth: 360 }}>
        <Heading as='h3' size={4} fontWeight={600}>Subscription</Heading>
        <Table fullWidth edgePadded={edgePadded}>
          <TableBody>
            {details.map(([label, value]) => (
              <TableRow key={label}>
                <TableData>{label}</TableData>
                <TableData>{value}</TableData>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </Card>
    </div>
  );
}
```

## Анатомия

### Обзор

`Table` владеет общим контекстом выравнивания и краевых отступов для семантической структуры таблицы. Это [составной компонент](https://vega-ui.dev/ru/docs/glossary/#составной-компонент-compound-component), потому что корень и секции работают вместе по одному контракту отображения данных.

### Обязательные части

#### `Table`

Обязательная. Отвечает за выравнивание, краевые отступы и флаги раскладки на всю ширину или высоту.

#### `TableRow`

Обязательная. Задаёт структуру строки внутри секций заголовка, тела или подвала.

#### `TableHeading` или `TableData`

Обязательная. Предоставляет сами семантические ячейки строки.

### Необязательные части

#### `TableHead`

Рекомендуется для подписей столбцов и доступной структуры столбцов.

#### `TableBody`

Рекомендуется для строк данных.

#### `TableFoot`

Необязательный подвал для итогов, сводок или агрегированных значений.

### Порядок композиции

1. `Table`
2. `TableHead`
3. `TableBody`
4. `TableFoot`
5. `TableRow`
6. `TableHeading`
7. `TableData`

### Корректные паттерны композиции

```tsx
import {
  Table,
  TableBody,
  TableData,
  TableFoot,
  TableHead,
  TableHeading,
  TableRow,
} from '@vega-ui/react';

const lines = [
  { item: 'Team plan · 12 seats', quantity: 12, unitPrice: 29 },
  { item: 'Extra storage · 500 GB', quantity: 1, unitPrice: 40 },
  { item: 'Priority support', quantity: 1, unitPrice: 99 },
];

const currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });

export default function SummaryTableWithFooterTotals() {
  const subtotal = lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0);
  const tax = subtotal * 0.2;

  return (
    <div style={{ width: '100%', border: '1px solid var(--separator-opaque)', borderRadius: 'var(--radius-4)', background: 'var(--surface-primary)' }}>
      <Table fullWidth edgePadded style={{ borderStyle: 'hidden' }}>
        <TableHead>
          <TableRow>
            <TableHeading scope='col'>Item</TableHeading>
            <TableHeading scope='col'>Qty</TableHeading>
            <TableHeading scope='col'>Amount</TableHeading>
          </TableRow>
        </TableHead>
        <TableBody>
          {lines.map((line) => (
            <TableRow key={line.item}>
              <TableData>{line.item}</TableData>
              <TableData>{line.quantity}</TableData>
              <TableData style={{ fontVariantNumeric: 'tabular-nums' }}>{currency.format(line.quantity * line.unitPrice)}</TableData>
            </TableRow>
          ))}
        </TableBody>
        <TableFoot>
          <TableRow>
            <TableHeading scope='row' colSpan={2} dataAlign='start' style={{ fontWeight: 400 }}>Subtotal</TableHeading>
            <TableData style={{ fontVariantNumeric: 'tabular-nums' }}>{currency.format(subtotal)}</TableData>
          </TableRow>
          <TableRow>
            <TableHeading scope='row' colSpan={2} dataAlign='start' style={{ fontWeight: 400 }}>VAT 20%</TableHeading>
            <TableData style={{ fontVariantNumeric: 'tabular-nums' }}>{currency.format(tax)}</TableData>
          </TableRow>
          <TableRow>
            <TableHeading scope='row' colSpan={2} dataAlign='start'>Total due</TableHeading>
            <TableData style={{ fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{currency.format(subtotal + tax)}</TableData>
          </TableRow>
        </TableFoot>
      </Table>
    </div>
  );
}
```

### Некорректные паттерны композиции

#### Визуальная раскладка таблицы без семантических частей

Это ослабляет нативный смысл таблицы и часто вредит доступности.

#### Произвольное смешение ролей заголовков и данных

Подписи столбцов должны оставаться в `TableHeading`, а не превращаться в стилизованные ячейки данных.

#### `Table` как интерактивный грид данных

Если функции нужны навигация по ячейкам, модели выбора или более богатое поведение грида, выбирайте `DataGrid`.
