# Card

## Кратко

`Card` — контейнер-поверхность для сгруппированного содержимого: панелей настроек, блоков дашборда и встроенных сводок. Это поверхность для раскладки и группировки, а не примитив обратной связи или статуса.

## Импорты

```tsx
import {
  Card,
  type CardProps,
  type CardSize,
  type CardAppearance,
} from '@vega-ui/react';
```

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

- `CardProps`
- `CardSize`
- `CardAppearance`

## Базовое использование

```tsx
import { Button, Card, Heading, Text } from '@vega-ui/react';

const features = ['Up to 25 members', '100 GB of storage', 'Priority email support', 'SAML single sign-on'];

export default function BasicUsage() {
  return (
    <Card aria-labelledby='plan-team-title' style={{ width: '100%', maxWidth: 320, gap: 'var(--spacing-6)' }}>
      <Heading id='plan-team-title' as='h3' size={4} fontWeight={600}>Team</Heading>
      <Text size={3}>
        <Text size={6} fontWeight={500}>$12</Text> per member / month
      </Text>
      <ul style={{ margin: 0, paddingInlineStart: 'var(--spacing-9)', display: 'grid', gap: 'var(--spacing-2)' }}>
        {features.map((feature) => (
          <li key={feature}><Text size={2} style={{ color: 'var(--label-secondary)' }}>{feature}</Text></li>
        ))}
      </ul>
      <Button fullWidth style={{ marginBlockStart: 'var(--spacing-6)' }}>Start 14-day trial</Button>
    </Card>
  );
}
```

## Варианты

- Размеры: `sm`, `md`, `lg`
- Оформление: `outline`, `transparent`
- Композиция: `article` по умолчанию или `asChild`

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

- `Card` — поверхность раскладки, а не семантическая замена заголовков или разделов.
- Прозрачные карточки работают, только когда контраст уже даёт родительская поверхность.
- Внутренние отступы по-прежнему определяет раскладка дочерних элементов.
- Если интерфейс пытается сообщить статус или срочность, а не сгруппировать содержимое, обычно понятнее подходят `Alert` или `Badge`.

## Частые ошибки

- Ожидать, что `Card` автоматически обеспечит все внутренние отступы раскладки.
- Использовать прозрачные карточки на визуально шумных фонах.
- Передавать иерархию только через оформление карточки.

## Примеры

### Базовый

#### Базовый: тарифный план

`Card` рендерит `article`, доступное имя которого берётся из заголовка через `aria-labelledby`. Содержимое выстраивается в колонку, а отступы между дочерними элементами задаёт `gap` на карточке.

```tsx
import { Button, Card, Heading, Text } from '@vega-ui/react';

const features = ['Up to 25 members', '100 GB of storage', 'Priority email support', 'SAML single sign-on'];

export default function BasicUsage() {
  return (
    <Card aria-labelledby='plan-team-title' style={{ width: '100%', maxWidth: 320, gap: 'var(--spacing-6)' }}>
      <Heading id='plan-team-title' as='h3' size={4} fontWeight={600}>Team</Heading>
      <Text size={3}>
        <Text size={6} fontWeight={500}>$12</Text> per member / month
      </Text>
      <ul style={{ margin: 0, paddingInlineStart: 'var(--spacing-9)', display: 'grid', gap: 'var(--spacing-2)' }}>
        {features.map((feature) => (
          <li key={feature}><Text size={2} style={{ color: 'var(--label-secondary)' }}>{feature}</Text></li>
        ))}
      </ul>
      <Button fullWidth style={{ marginBlockStart: 'var(--spacing-6)' }}>Start 14-day trial</Button>
    </Card>
  );
}
```

#### Базовый: вся карточка — ссылка

С `asChild` стили карточки переходят на `<a>`, и вся поверхность становится одной ссылкой с одним доступным именем. Содержимое такой карточки должно быть неинтерактивным: без кнопок и вложенных ссылок.

```tsx
import { Card, Heading, Text } from '@vega-ui/react';
import './examples.css';

const destinations = [
  { href: '#billing', title: 'Billing', description: 'Plan, invoices and payment methods.' },
  { href: '#members', title: 'Members', description: '18 members, 3 pending invitations.' },
  { href: '#security', title: 'Security', description: 'Two-factor authentication, sessions and SSO.' },
];

export default function LinkCards() {
  return (
    <nav aria-label='Workspace settings' style={{ width: '100%' }}>
      <ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(min(200px, 100%), 1fr))', gap: 'var(--spacing-6)' }}>
        {destinations.map((destination) => (
          <li key={destination.href} style={{ display: 'grid' }}>
            <Card asChild size='sm' className='linkCard'>
              <a href={destination.href} style={{ color: 'inherit', textDecoration: 'none', gap: 'var(--spacing-2)' }}>
                <Heading as='h3' size={3} fontWeight={600}>{destination.title}</Heading>
                <Text size={2} style={{ color: 'var(--label-secondary)' }}>{destination.description}</Text>
              </a>
            </Card>
          </li>
        ))}
      </ul>
    </nav>
  );
}
```

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

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

Значение, время обновления и флаг загрузки хранятся в состоянии. Во время обновления карточка получает `aria-busy`, у спиннера своё доступное имя, а кнопка отключена.

```tsx
import { useState } from 'react';
import { Button, Card, Heading, Spinner, Text } from '@vega-ui/react';

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

export default function MetricsCard() {
  const [revenue, setRevenue] = useState(48210.4);
  const [updatedAt, setUpdatedAt] = useState('09:00');
  const [loading, setLoading] = useState(false);

  const refresh = () => {
    setLoading(true);
    setTimeout(() => {
      const now = new Date();
      setRevenue((current) => current + Math.round(Math.random() * 90000) / 100);
      setUpdatedAt(now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }));
      setLoading(false);
    }, 900);
  };

  return (
    <Card aria-labelledby='revenue-title' aria-busy={loading} style={{ width: '100%', maxWidth: 300, gap: 'var(--spacing-2)' }}>
      <Heading id='revenue-title' as='h3' size={2} fontWeight={500} style={{ color: 'var(--label-secondary)' }}>Revenue this month</Heading>
      <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)', minHeight: 36 }}>
        <Text size={7} fontWeight={500} style={{ fontVariantNumeric: 'tabular-nums' }}>{formatter.format(revenue)}</Text>
        {loading && <Spinner size={3} aria-label='Refreshing revenue' />}
      </div>
      <Text size={2} style={{ color: 'var(--label-tertiary)', fontVariantNumeric: 'tabular-nums' }}>Updated at {updatedAt}</Text>
      <Button size='sm' variant='secondary' appearance='ghost' disabled={loading} onClick={refresh} style={{ marginBlockStart: 'var(--spacing-6)' }}>Refresh</Button>
    </Card>
  );
}
```

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

#### Форма/интеграция: карточка и есть форма

`asChild` рендерит карточку как `<form>` с доступным именем из заголовка. Значения читаются через `FormData` при отправке, нативные `required` и `pattern` проверяют поля, а сброс очищает сообщение о статусе.

```tsx
import { FormEvent, useState } from 'react';
import { Button, Card, Heading, Label, Text, TextField, TextFieldInput } from '@vega-ui/react';

export default function SettingsFormCard() {
  const [saved, setSaved] = useState('');

  const submit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setSaved(`Saved “${data.get('workspace')}” at ${data.get('slug')}.vega.app`);
  };

  return (
    <Card asChild style={{ width: '100%', maxWidth: 420, gap: 'var(--spacing-12)' }}>
      <form aria-labelledby='workspace-card-title' onSubmit={submit} onReset={() => setSaved('')}>
        <div style={{ display: 'grid', gap: 'var(--spacing-2)' }}>
          <Heading id='workspace-card-title' as='h3' size={4} fontWeight={600}>Workspace</Heading>
          <Text size={2} style={{ color: 'var(--label-secondary)' }}>Shown in invitations and in the browser tab.</Text>
        </div>
        <div style={{ display: 'grid', gap: 'var(--spacing-8)', alignSelf: 'stretch' }}>
          <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
            <Label htmlFor='workspace-card-name'>Name</Label>
            <TextField>
              <TextFieldInput id='workspace-card-name' name='workspace' defaultValue='Northwind Studio' required />
            </TextField>
          </div>
          <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
            <Label htmlFor='workspace-card-slug'>URL</Label>
            <TextField>
              <TextFieldInput id='workspace-card-slug' name='slug' defaultValue='northwind' pattern='[a-z0-9-]{3,32}' required />
            </TextField>
          </div>
        </div>
        <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 'var(--spacing-4)', alignSelf: 'stretch' }}>
          <Button type='submit' size='sm'>Save</Button>
          <Button type='reset' size='sm' variant='secondary' appearance='ghost'>Reset</Button>
          <Text size={2} role='status' style={{ color: 'var(--label-secondary)', overflowWrap: 'anywhere' }}>{saved}</Text>
        </div>
      </form>
    </Card>
  );
}
```

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

#### Раскладка/оверлей: адаптивная сетка с выровненными действиями

`auto-fill` с `minmax(min(220px, 100%), 1fr)` даёт столько колонок, сколько помещается, и не вызывает переполнения на телефонах. Строки сетки растягивают карточки до одной высоты, а `margin-block-start: auto` прижимает кнопки к низу независимо от длины текста.

```tsx
import { Button, Card, Heading, Text } from '@vega-ui/react';

const integrations = [
  { id: 'slack', name: 'Slack', description: 'Post deployment and incident updates to channels.' },
  { id: 'github', name: 'GitHub', description: 'Link pull requests to releases and show build status on every commit, including drafts and forks.' },
  { id: 'pagerduty', name: 'PagerDuty Incident Response Orchestration', description: 'Page the on-call engineer.' },
];

export default function ResponsiveGrid() {
  return (
    <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(min(220px, 100%), 1fr))', gap: 'var(--spacing-6)', width: '100%' }}>
      {integrations.map((integration) => (
        <Card key={integration.id} size='sm' aria-labelledby={`integration-${integration.id}`} style={{ gap: 'var(--spacing-4)' }}>
          <Heading id={`integration-${integration.id}`} as='h3' size={3} fontWeight={600}>{integration.name}</Heading>
          <Text size={2} style={{ color: 'var(--label-secondary)' }}>{integration.description}</Text>
          <Button size='sm' variant='secondary' appearance='ghost' aria-describedby={`integration-${integration.id}`} style={{ marginBlockStart: 'auto' }}>
            Connect
          </Button>
        </Card>
      ))}
    </div>
  );
}
```

### Ошибка

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

Карточка сохраняет заголовок и объясняет сбой в области `role='alert'` с действием повтора. Повтор показывает состояние загрузки перед появлением данных.

```tsx
import { useState } from 'react';
import { Button, Card, Heading, Spinner, Text } from '@vega-ui/react';

type State = 'failed' | 'loading' | 'loaded';

export default function LoadErrorCard() {
  const [state, setState] = useState<State>('failed');

  const retry = () => {
    setState('loading');
    setTimeout(() => setState('loaded'), 900);
  };

  return (
    <Card aria-labelledby='usage-card-title' aria-busy={state === 'loading'} style={{ width: '100%', maxWidth: 340, gap: 'var(--spacing-4)' }}>
      <Heading id='usage-card-title' as='h3' size={3} fontWeight={600}>API usage</Heading>
      {state === 'loading' && <Spinner size={3} aria-label='Loading API usage' />}
      {state === 'failed' && (
        <div role='alert' style={{ display: 'grid', gap: 'var(--spacing-6)', justifyItems: 'start' }}>
          <Text size={2} style={{ color: 'var(--label-secondary)' }}>Usage could not be loaded: the metrics service timed out.</Text>
          <Button size='sm' variant='secondary' appearance='ghost' onClick={retry}>Try again</Button>
        </div>
      )}
      {state === 'loaded' && (
        <Text size={3}>1.82 M of 2 M requests used this month</Text>
      )}
    </Card>
  );
}
```

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

#### Отключённый: данные только для чтения для не-владельцев

У `Card` нет собственного отключённого состояния. `<fieldset disabled>` внутри карточки отключает все поля сразу, а объяснение связано через `aria-describedby`.

```tsx
import { Card, Heading, Label, Text, TextField, TextFieldInput } from '@vega-ui/react';

export default function ReadOnlyBilling() {
  return (
    <Card aria-labelledby='billing-contact-title' style={{ width: '100%', maxWidth: 420, gap: 'var(--spacing-12)' }}>
      <div style={{ display: 'grid', gap: 'var(--spacing-2)' }}>
        <Heading id='billing-contact-title' as='h3' size={4} fontWeight={600}>Billing contact</Heading>
        <Text id='billing-contact-note' size={2} style={{ color: 'var(--label-secondary)' }}>
          Only workspace owners can change billing details. Ask Maria Chen to update them.
        </Text>
      </div>
      <fieldset disabled aria-describedby='billing-contact-note' style={{ border: 0, margin: 0, padding: 0, display: 'grid', gap: 'var(--spacing-8)', alignSelf: 'stretch' }}>
        <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
          <Label htmlFor='billing-contact-name'>Name</Label>
          <TextField>
            <TextFieldInput id='billing-contact-name' defaultValue='Maria Chen' />
          </TextField>
        </div>
        <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
          <Label htmlFor='billing-contact-email'>Email</Label>
          <TextField>
            <TextFieldInput id='billing-contact-email' type='email' defaultValue='billing@northwind.io' />
          </TextField>
        </div>
      </fieldset>
    </Card>
  );
}
```

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

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

Одна и та же карточка справляется и с нулём, и с 240 элементами. Пустое состояние объясняет, что делать дальше; длинный список прокручивается внутри карточки, а область прокрутки фокусируема и имеет имя, чтобы её можно было прокрутить с клавиатуры.

```tsx
import { useState } from 'react';
import { Button, Card, Heading, Text } from '@vega-ui/react';

const allKeys = Array.from({ length: 240 }, (_, index) => `sk_live_${String(index + 1).padStart(4, '0')}`);

export default function EmptyAndMany() {
  const [keys, setKeys] = useState<string[]>([]);

  const toggle = () => {
    if (keys.length === 0) {
      setKeys(allKeys);
      return;
    }
    setKeys([]);
  };

  return (
    <Card aria-labelledby='api-keys-title' style={{ width: '100%', maxWidth: 360, gap: 'var(--spacing-6)' }}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-4)', alignSelf: 'stretch' }}>
        <Heading id='api-keys-title' as='h3' size={3} fontWeight={600} style={{ fontVariantNumeric: 'tabular-nums' }}>API keys ({keys.length})</Heading>
        <Button size='sm' variant='secondary' appearance='ghost' onClick={toggle}>
          {keys.length === 0 ? 'Load 240 keys' : 'Clear'}
        </Button>
      </div>
      {keys.length === 0 && (
        <Text size={2} style={{ color: 'var(--label-secondary)' }}>No API keys yet. Create one to call the API from your servers.</Text>
      )}
      {keys.length > 0 && (
        <ul
          tabIndex={0}
          aria-label='API keys'
          style={{ margin: 0, padding: 0, listStyle: 'none', maxHeight: 200, overflowY: 'auto', alignSelf: 'stretch' }}
        >
          {keys.map((key) => (
            <li key={key} style={{ paddingBlock: 'var(--spacing-2)' }}>
              <Text size={2} style={{ fontFamily: 'var(--code-font-family)' }}>{key}</Text>
            </li>
          ))}
        </ul>
      )}
    </Card>
  );
}
```

## Анатомия

### Обзор

`Card` — единый контейнер-поверхность для группировки без публичных дочерних частей. Его анатомия — одна корневая поверхность плюс заголовки, основной текст, метрики или действия, которые размещает потребитель.

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

#### `Card`

Обязательна. Владеет оформлением поверхности, внутренними отступами и необязательным полиморфным рендером через `asChild`.

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

`Card` не предоставляет публичных дочерних частей.

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

1. `Card`
2. заголовок, текст, действия или дочерние элементы раскладки

### Допустимые варианты композиции

```tsx
import { Button, Card, Heading, Text } from '@vega-ui/react';

const features = ['Up to 25 members', '100 GB of storage', 'Priority email support', 'SAML single sign-on'];

export default function BasicUsage() {
  return (
    <Card aria-labelledby='plan-team-title' style={{ width: '100%', maxWidth: 320, gap: 'var(--spacing-6)' }}>
      <Heading id='plan-team-title' as='h3' size={4} fontWeight={600}>Team</Heading>
      <Text size={3}>
        <Text size={6} fontWeight={500}>$12</Text> per member / month
      </Text>
      <ul style={{ margin: 0, paddingInlineStart: 'var(--spacing-9)', display: 'grid', gap: 'var(--spacing-2)' }}>
        {features.map((feature) => (
          <li key={feature}><Text size={2} style={{ color: 'var(--label-secondary)' }}>{feature}</Text></li>
        ))}
      </ul>
      <Button fullWidth style={{ marginBlockStart: 'var(--spacing-6)' }}>Start 14-day trial</Button>
    </Card>
  );
}
```

### Недопустимые варианты композиции

#### Карточка как единственная семантическая структура

Поверхность не должна заменять заголовки, ориентиры или настоящую иерархию содержимого.

#### Прозрачная карточка на низкоконтрастном фоне

Сгруппированное содержимое может полностью потерять визуальное отделение.
