# VisuallyHidden

## Кратко

`VisuallyHidden` оставляет содержимое доступным для вспомогательных технологий, убирая его из визуальной раскладки. Поддерживает нативную обёртку или композицию через `asChild` с помощью `Slot`.

## Импорт

```tsx
import { VisuallyHidden, type VisuallyHiddenProps } from '@vega-ui/react';
```

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

- `VisuallyHiddenProps`

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

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

const plans = [
  { name: 'Starter', price: '$0', slug: 'starter' },
  { name: 'Team', price: '$12', slug: 'team' },
  { name: 'Enterprise', price: 'Custom', slug: 'enterprise' },
];

export default function BasicUsage() {
  return (
    <ul style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 'var(--spacing-4)', width: '100%', margin: 0, padding: 0, listStyle: 'none' }}>
      {plans.map((plan) => (
        <li
          key={plan.slug}
          style={{
            display: 'flex',
            flexDirection: 'column',
            gap: 'var(--spacing-2)',
            padding: 'var(--spacing-6)',
            border: '1px solid var(--separator-opaque)',
            borderRadius: 'var(--radius-4)',
            background: 'var(--surface-primary)',
          }}
        >
          <Heading as='h3' size={3} fontWeight={600}>{plan.name}</Heading>
          <Text size={3} style={{ color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums' }}>{plan.price}</Text>
          <Link href={`/pricing/${plan.slug}`} style={{ marginBlockStart: 'var(--spacing-4)', fontSize: 'var(--font-size-2)' }}>
            Learn more
            <VisuallyHidden asChild>
              <span> about the {plan.name} plan</span>
            </VisuallyHidden>
          </Link>
        </li>
      ))}
    </ul>
  );
}
```

## Варианты

- Рендер: обёртка по умолчанию или `asChild`
- Применение: скрытая подпись, описание или текст статуса

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

- Скрытый текст должен оставаться синхронизированным с видимым состоянием интерфейса.
- `VisuallyHidden` по умолчанию не заменяет видимые подписи.
- `asChild` сохраняет стили скрытия, но меняет нижележащий элемент.

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

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

## Примеры

### Базовый

#### Базовый: уникальные имена для повторяющихся ссылок

Три ссылки «Learn more» в списке ссылок звучат одинаково. Скрытый текст дополняет каждое имя («Learn more about the Team plan»), не меняя раскладку.

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

const plans = [
  { name: 'Starter', price: '$0', slug: 'starter' },
  { name: 'Team', price: '$12', slug: 'team' },
  { name: 'Enterprise', price: 'Custom', slug: 'enterprise' },
];

export default function BasicUsage() {
  return (
    <ul style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 'var(--spacing-4)', width: '100%', margin: 0, padding: 0, listStyle: 'none' }}>
      {plans.map((plan) => (
        <li
          key={plan.slug}
          style={{
            display: 'flex',
            flexDirection: 'column',
            gap: 'var(--spacing-2)',
            padding: 'var(--spacing-6)',
            border: '1px solid var(--separator-opaque)',
            borderRadius: 'var(--radius-4)',
            background: 'var(--surface-primary)',
          }}
        >
          <Heading as='h3' size={3} fontWeight={600}>{plan.name}</Heading>
          <Text size={3} style={{ color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums' }}>{plan.price}</Text>
          <Link href={`/pricing/${plan.slug}`} style={{ marginBlockStart: 'var(--spacing-4)', fontSize: 'var(--font-size-2)' }}>
            Learn more
            <VisuallyHidden asChild>
              <span> about the {plan.name} plan</span>
            </VisuallyHidden>
          </Link>
        </li>
      ))}
    </ul>
  );
}
```

#### Базовый: кнопка-иконка с текстовым именем

Скрытый текст внутри кнопки становится её доступным именем и может содержать данные, которые бейдж показывает лишь числом. Сам бейдж помечен `aria-hidden`, чтобы число не читалось дважды. `asChild` рендерит `<span>`, потому что `<div>` по умолчанию недопустим внутри кнопки.

```tsx
import { Icon, IconButton, VisuallyHidden } from '@vega-ui/react';
import { BellIcon } from '@vega-ui/icons';
import { useState } from 'react';

export default function IconOnlyControlLabel() {
  const [unread, setUnread] = useState(4);

  return (
    <IconButton type='button' variant='secondary' appearance='ghost' onClick={() => setUnread(0)} style={{ position: 'relative' }}>
      <Icon><BellIcon /></Icon>
      <VisuallyHidden asChild>
        <span>Notifications, {unread} unread</span>
      </VisuallyHidden>
      {unread > 0 && (
        <span
          aria-hidden='true'
          style={{
            position: 'absolute',
            insetBlockStart: 'var(--spacing-1)',
            insetInlineEnd: 'var(--spacing-1)',
            minWidth: 'var(--spacing-8)',
            paddingInline: 'var(--spacing-2)',
            boxSizing: 'border-box',
            borderRadius: 999,
            background: 'var(--color-error)',
            color: 'var(--color-white)',
            fontSize: 'var(--font-size-1)',
            lineHeight: 'var(--spacing-8)',
            textAlign: 'center',
            fontVariantNumeric: 'tabular-nums',
          }}
        >
          {unread}
        </span>
      )}
    </IconButton>
  );
}
```

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

#### Контролируемый/с состоянием: живое объявление для визуального счётчика

Счётчик корзины меняется беззвучно для скринридера. Скрытая область `role='status'` получает полное предложение при каждом изменении, а визуальный счётчик остаётся `aria-hidden`.

```tsx
import { Button, Icon, VisuallyHidden } from '@vega-ui/react';
import { ShoppingCartIcon } from '@vega-ui/icons';
import { useState } from 'react';

const products = ['Linen shirt', 'Canvas tote', 'Wool socks'];

export default function HiddenStatusText() {
  const [cart, setCart] = useState<string[]>([]);
  const [announcement, setAnnouncement] = useState('');

  const add = (product: string) => {
    const next = [...cart, product];
    setCart(next);
    setAnnouncement(`${product} added. Cart has ${next.length} items.`);
  };

  return (
    <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 'var(--spacing-8)', width: '100%' }}>
      <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 'var(--spacing-4)' }}>
        {products.map((product) => (
          <Button key={product} type='button' size='sm' variant='secondary' appearance='ghost' onClick={() => add(product)}>
            Add {product}
          </Button>
        ))}
      </div>
      <span aria-hidden='true' style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-3)', fontVariantNumeric: 'tabular-nums' }}>
        <Icon size='sm'><ShoppingCartIcon /></Icon>
        {cart.length}
      </span>
      <VisuallyHidden asChild>
        <p role='status'>{announcement}</p>
      </VisuallyHidden>
    </div>
  );
}
```

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

#### Форма/интеграция: скрытое описание визуального украшения

Префикс `$` только визуальный. Скрытое описание, связанное через `aria-describedby`, сообщает скринридеру валюту; значение читается через `FormData` при отправке. `VisuallyHidden` не принимает `id`, поэтому id задаётся на `span` через `asChild`.

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

export default function HiddenHelperDescription() {
  const [budget, setBudget] = useState('');

  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    setBudget(String(new FormData(event.currentTarget).get('budget')));
  };

  return (
    <form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%' }}>
      <Label htmlFor='monthly-budget'>Monthly budget</Label>
      <TextField style={{ paddingInlineStart: 'var(--spacing-8)', gap: 'var(--spacing-2)' }}>
        <span aria-hidden='true' style={{ color: 'var(--label-secondary)' }}>$</span>
        <TextFieldInput
          id='monthly-budget'
          name='budget'
          type='number'
          min={0}
          step={50}
          defaultValue={500}
          aria-describedby='monthly-budget-currency'
          required
          style={{ paddingInlineStart: 0, fontVariantNumeric: 'tabular-nums' }}
        />
      </TextField>
      <VisuallyHidden asChild>
        <span id='monthly-budget-currency'>Amount in US dollars</span>
      </VisuallyHidden>
      <Button type='submit' style={{ alignSelf: 'flex-start', marginBlockStart: 'var(--spacing-9)' }}>Save budget</Button>
      <output style={{ color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums' }}>
        {budget === '' ? '' : `Budget set to $${budget}`}
      </output>
    </form>
  );
}
```

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

#### Раскладка/оверлей: скрытый заголовок диалога

У лайтбокса нет видимого заголовка, но диалогу всё равно нужно имя. Скрытый `<h2>` подключён через `aria-labelledby`, поэтому диалог объявляется как «Floor plan, apartment 4B».

```tsx
import type { CSSProperties } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Icon,
  IconButton,
  VisuallyHidden,
} from '@vega-ui/react';
import { ImageIcon, XIcon } from '@vega-ui/icons';

export default function HiddenDialogCue() {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant='secondary' appearance='ghost'>
          <Icon size='sm'><ImageIcon /></Icon>
          View floor plan
        </Button>
      </DialogTrigger>
      <DialogContent aria-labelledby='floor-plan-title'>
        <VisuallyHidden asChild>
          <h2 id='floor-plan-title'>Floor plan, apartment 4B</h2>
        </VisuallyHidden>
        <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
          <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='img'
          aria-label='Two bedrooms, open kitchen, balcony facing south, 74 square meters'
          style={{
            display: 'grid',
            gridTemplateColumns: '2fr 1fr',
            gap: 'var(--spacing-2)',
            height: 220,
            marginBlockStart: 'var(--spacing-8)',
          }}
        >
          <div style={{ background: 'var(--fills-secondary)', borderRadius: 'var(--radius-2)' }} />
          <div style={{ display: 'grid', gap: 'var(--spacing-2)' }}>
            <div style={{ background: 'var(--fills-tertiary)', borderRadius: 'var(--radius-2)' }} />
            <div style={{ background: 'var(--fills-primary)', borderRadius: 'var(--radius-2)' }} />
          </div>
        </div>
      </DialogContent>
    </Dialog>
  );
}
```

### Ошибка

#### Ошибка: озвучиваемый префикс сообщения об ошибке

Ошибка показана цветом и иконкой. Скрытый префикс «Error:» даёт тот же сигнал пользователям скринридера, когда они попадают на сообщение через `aria-describedby`.

```tsx
import { Button, HelperText, Icon, Label, TextField, TextFieldInput, VisuallyHidden } from '@vega-ui/react';
import { CircleAlertIcon } from '@vega-ui/icons';
import { FormEvent, useState } from 'react';

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

  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const domain = String(new FormData(event.currentTarget).get('domain'));

    if (!domain.includes('.')) {
      setError('Add a top-level domain, for example acme.com');
      setSaved('');
      return;
    }

    setError('');
    setSaved(domain);
  };

  return (
    <form noValidate onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%' }}>
      <Label htmlFor='custom-domain'>Custom domain</Label>
      <TextField error={error !== ''}>
        <TextFieldInput
          id='custom-domain'
          name='domain'
          defaultValue='acme'
          aria-invalid={error !== ''}
          aria-describedby={error === '' ? undefined : 'custom-domain-error'}
        />
      </TextField>
      {error !== '' && (
        <HelperText id='custom-domain-error' error style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-3)' }}>
          <Icon size='xs' style={{ flexShrink: 0 }}><CircleAlertIcon /></Icon>
          <VisuallyHidden asChild>
            <span>Error:</span>
          </VisuallyHidden>
          {error}
        </HelperText>
      )}
      <Button type='submit' style={{ alignSelf: 'flex-start', marginBlockStart: 'var(--spacing-9)' }}>Connect domain</Button>
      <output style={{ color: 'var(--label-secondary)' }}>{saved === '' ? '' : `Connected ${saved}`}</output>
    </form>
  );
}
```

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

#### Отключённый: почему вариант недоступен

Распроданные размеры отключены и зачёркнуты. Зачёркивание только визуальное, поэтому скрытый текст добавляет «, sold out» к имени кнопки — скринридер читает его и в режиме просмотра.

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

const sizes = [
  { label: 'S', inStock: true },
  { label: 'M', inStock: true },
  { label: 'L', inStock: false },
  { label: 'XL', inStock: false },
];

export default function HiddenDisabledExplanation() {
  const [selected, setSelected] = useState('M');

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-6)', width: '100%' }}>
      <div role='group' aria-label='Size' style={{ display: 'flex', gap: 'var(--spacing-4)' }}>
        {sizes.map((size) => (
          <Button
            key={size.label}
            type='button'
            size='sm'
            variant={selected === size.label ? 'primary' : 'secondary'}
            appearance='ghost'
            aria-pressed={selected === size.label}
            disabled={!size.inStock}
            onClick={() => setSelected(size.label)}
            style={{ textDecoration: size.inStock ? 'none' : 'line-through' }}
          >
            {size.label}
            {!size.inStock && (
              <VisuallyHidden asChild>
                <span>, sold out</span>
              </VisuallyHidden>
            )}
          </Button>
        ))}
      </div>
      <p style={{ margin: 0, color: 'var(--label-secondary)' }}>Selected size: {selected}</p>
    </div>
  );
}
```

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

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

Символы звёзд читаются по одному как «чёрная звезда». Они скрыты через `aria-hidden`, а скрытый `span` внутри абзаца читается как «Rated 3 out of 5». `asChild` оставляет `<span>` внутри `<p>`, где `<div>` недопустим.

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

const reviews = [
  { author: 'Maria K.', rating: 5, text: 'Arrived in two days, fits perfectly.' },
  { author: 'Tom B.', rating: 3, text: 'Good fabric, but the sleeves run long.' },
];

export default function SlottedHiddenElement() {
  return (
    <ul style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-8)', width: '100%', margin: 0, padding: 0, listStyle: 'none' }}>
      {reviews.map((review) => (
        <li key={review.author} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-1)' }}>
          <Text size={3} fontWeight={500}>{review.author}</Text>
          <p style={{ margin: 0 }}>
            <span aria-hidden='true' style={{ color: 'var(--color-primary-500)', letterSpacing: 'var(--spacing-1)' }}>
              {'★'.repeat(review.rating)}{'☆'.repeat(5 - review.rating)}
            </span>
            <VisuallyHidden asChild>
              <span>Rated {review.rating} out of 5</span>
            </VisuallyHidden>
          </p>
          <Text size={3} style={{ color: 'var(--label-secondary)' }}>{review.text}</Text>
        </li>
      ))}
    </ul>
  );
}
```

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

У колонки действий часто нет видимого заголовка, но пустой `<th>` оставляет ячейки без контекста. Скрытый заголовок «Actions» решает это, а колонка визуально остаётся пустой.

```tsx
import { Icon, IconButton, VisuallyHidden } from '@vega-ui/react';
import { PencilIcon } from '@vega-ui/icons';
import { useState } from 'react';

const members = [
  { name: 'Jane Cooper', role: 'Owner' },
  { name: 'Wade Warren', role: 'Editor' },
  { name: 'Esther Howard', role: 'Viewer' },
];

export default function HiddenTableHeader() {
  const [editing, setEditing] = useState('');

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-6)', width: '100%' }}>
      <table style={{ width: '100%', borderCollapse: 'collapse' }}>
        <thead>
          <tr>
            <th scope='col' style={{ paddingBlock: 'var(--spacing-3)', borderBlockEnd: '1px solid var(--separator-opaque)', textAlign: 'start' }}>Name</th>
            <th scope='col' style={{ paddingBlock: 'var(--spacing-3)', borderBlockEnd: '1px solid var(--separator-opaque)', textAlign: 'start' }}>Role</th>
            <th scope='col' style={{ paddingBlock: 'var(--spacing-3)', borderBlockEnd: '1px solid var(--separator-opaque)' }}>
              <VisuallyHidden>Actions</VisuallyHidden>
            </th>
          </tr>
        </thead>
        <tbody>
          {members.map((member) => (
            <tr key={member.name}>
              <td style={{ paddingBlock: 'var(--spacing-3)', borderBlockEnd: '1px solid var(--separator-opaque)' }}>{member.name}</td>
              <td style={{ paddingBlock: 'var(--spacing-3)', borderBlockEnd: '1px solid var(--separator-opaque)', color: 'var(--label-secondary)' }}>{member.role}</td>
              <td style={{ paddingBlock: 'var(--spacing-3)', borderBlockEnd: '1px solid var(--separator-opaque)', textAlign: 'end' }}>
                <IconButton
                  style={{ display: 'inline-flex' }}
                  type='button'
                  size='xs'
                  variant='secondary'
                  appearance='transparent'
                  aria-label={`Edit ${member.name}`}
                  onClick={() => setEditing(member.name)}
                >
                  <Icon><PencilIcon /></Icon>
                </IconButton>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
      <p style={{ margin: 0, color: 'var(--label-secondary)' }}>{editing === '' ? '' : `Editing ${editing}`}</p>
    </div>
  );
}
```

## Анатомия

### Обзор

`VisuallyHidden` — примитив из одной части, который применяет к своему содержимому стандартный CSS-паттерн визуального скрытия.

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

#### `VisuallyHidden`

Обязательна. Отвечает за рендер, при котором содержимое скрыто визуально, но остаётся доступным.

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

Экспортируемых дочерних частей нет.

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

1. `VisuallyHidden`
2. скрытое содержимое

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

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

const plans = [
  { name: 'Starter', price: '$0', slug: 'starter' },
  { name: 'Team', price: '$12', slug: 'team' },
  { name: 'Enterprise', price: 'Custom', slug: 'enterprise' },
];

export default function BasicUsage() {
  return (
    <ul style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 'var(--spacing-4)', width: '100%', margin: 0, padding: 0, listStyle: 'none' }}>
      {plans.map((plan) => (
        <li
          key={plan.slug}
          style={{
            display: 'flex',
            flexDirection: 'column',
            gap: 'var(--spacing-2)',
            padding: 'var(--spacing-6)',
            border: '1px solid var(--separator-opaque)',
            borderRadius: 'var(--radius-4)',
            background: 'var(--surface-primary)',
          }}
        >
          <Heading as='h3' size={3} fontWeight={600}>{plan.name}</Heading>
          <Text size={3} style={{ color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums' }}>{plan.price}</Text>
          <Link href={`/pricing/${plan.slug}`} style={{ marginBlockStart: 'var(--spacing-4)', fontSize: 'var(--font-size-2)' }}>
            Learn more
            <VisuallyHidden asChild>
              <span> about the {plan.name} plan</span>
            </VisuallyHidden>
          </Link>
        </li>
      ))}
    </ul>
  );
}
```

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

#### Скрытый текст по умолчанию вместо видимой подписи

Интерфейсом становится труднее пользоваться зрячим пользователям.

#### Скрытое содержимое не синхронизировано с видимым состоянием

Пользователи вспомогательных технологий получают устаревшую информацию.
