# TextArea

## Кратко

`TextArea` — многострочное поле ввода для длинного содержимого в свободной форме. Оно сочетает семантику нативного textarea со стилями размера, полной ширины и ошибки — для случаев, когда значение в продукте намеренно многострочное.

## Импорт

```tsx
import { TextArea, type TextAreaProps, type TextAreaSize } from '@vega-ui/react';
```

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

- `TextAreaProps`
- `TextAreaSize = 'sm' | 'md' | 'lg' | string`

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

```tsx
import { HelperText, Label, TextArea } from '@vega-ui/react';
import { useId } from 'react';

export default function IssueDescription() {
  const id = useId();
  const hintId = `${id}-hint`;

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-3)', width: '100%' }}>
      <Label htmlFor={id}>What went wrong?</Label>
      <TextArea
        id={id}
        name='description'
        rows={4}
        fullWidth
        placeholder='Steps to reproduce, what you expected and what happened instead'
        aria-describedby={hintId}
      />
      <HelperText id={hintId}>Include the page URL and the time it happened, if you know it.</HelperText>
    </div>
  );
}
```

## Варианты

- Размер: `sm`, `md`, `lg`
- Состояние: по умолчанию, `error`, отключённое
- Раскладка: обычная ширина или `fullWidth`
- Модель значения: контролируемая или неконтролируемая

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

- Длинное содержимое и изменение размера нужно тестировать в стеснённых раскладках.
- `fullWidth` меняет ширину, но не стратегию высоты.
- Для многострочного содержимого `TextArea` подходит лучше, чем попытки добиться многострочности от `TextField`.
- Валидация, вспомогательный текст и раскладка должны учитывать длинное содержимое, а не только первые видимые строки.

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

- Использовать `TextArea` для коротких однострочных значений.
- Забывать про вспомогательный текст или подписи вокруг поля для длинного ввода.
- Считать `error` заменой настоящим сообщениям валидации.

## Примеры

### Базовый

#### Базовый: описание проблемы с подсказкой

`TextArea` — это сам нативный `<textarea>`, поэтому `id`, `name` и `rows` ставятся прямо на него. `Label` указывает на поле через `htmlFor`, а подсказка связана через `aria-describedby`.

```tsx
import { HelperText, Label, TextArea } from '@vega-ui/react';
import { useId } from 'react';

export default function IssueDescription() {
  const id = useId();
  const hintId = `${id}-hint`;

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-3)', width: '100%' }}>
      <Label htmlFor={id}>What went wrong?</Label>
      <TextArea
        id={id}
        name='description'
        rows={4}
        fullWidth
        placeholder='Steps to reproduce, what you expected and what happened instead'
        aria-describedby={hintId}
      />
      <HelperText id={hintId}>Include the page URL and the time it happened, if you know it.</HelperText>
    </div>
  );
}
```

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

#### Контролируемый/с состоянием: счётчик и живое превью

Значением владеет родитель: счётчик и превью списка изменений вычисляются из него на каждое нажатие. `maxLength` — жёсткий лимит, ввод останавливается на 280 символах.

```tsx
import { Label, Text, TextArea } from '@vega-ui/react';
import { useId, useState } from 'react';

const MAX_LENGTH = 280;

export default function ReleaseNotesCounter() {
  const id = useId();
  const counterId = `${id}-counter`;
  const [notes, setNotes] = useState('Faster search across archived invoices.\nDark mode for the billing portal.');
  const lines = notes.split('\n').filter((line) => line.trim() !== '');

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-12)', width: '100%' }}>
      <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
        <Label htmlFor={id}>Release highlights</Label>
        <TextArea
          id={id}
          rows={4}
          fullWidth
          value={notes}
          maxLength={MAX_LENGTH}
          aria-describedby={counterId}
          onChange={(event) => setNotes(event.currentTarget.value)}
        />
        <Text id={counterId} size={2} style={{ color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums' }}>
          {notes.length} / {MAX_LENGTH} characters
        </Text>
      </div>
      <div style={{ display: 'grid', gap: 'var(--spacing-4)' }}>
        <Text size={2} fontWeight={500} style={{ color: 'var(--label-secondary)' }}>Changelog preview</Text>
        <ul style={{ display: 'grid', gap: 'var(--spacing-2)', margin: 0, paddingInlineStart: 'var(--spacing-10)' }}>
          {lines.map((line, index) => (
            <li key={index}><Text size={2} style={{ overflowWrap: 'anywhere' }}>{line}</Text></li>
          ))}
        </ul>
      </div>
    </div>
  );
}
```

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

#### Форма/интеграция: обращение в поддержку с горячей клавишей

`required` и `minLength` включают нативную валидацию, а `FormData` читает сообщение при отправке. Ctrl+Enter или ⌘+Enter вызывают `requestSubmit()`, поэтому горячая клавиша проходит ту же валидацию, что и кнопка.

```tsx
import { Button, HelperText, Label, Text, TextArea } from '@vega-ui/react';
import { FormEvent, KeyboardEvent, useId, useState } from 'react';

export default function SupportRequestForm() {
  const id = useId();
  const [sent, setSent] = useState('');

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const message = String(new FormData(event.currentTarget).get('message'));
    setSent(`Sent ${message.length} characters`);
    event.currentTarget.reset();
  };

  const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
    if (event.key !== 'Enter') return;
    if (!event.metaKey && !event.ctrlKey) return;

    event.preventDefault();
    event.currentTarget.form?.requestSubmit();
  };

  return (
    <form onSubmit={onSubmit} style={{ display: 'grid', gap: 'var(--spacing-12)', width: '100%' }}>
      <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
        <Label htmlFor={id}>Message to support</Label>
        <TextArea
          id={id}
          name='message'
          rows={5}
          fullWidth
          required
          minLength={20}
          aria-describedby={`${id}-hint`}
          onKeyDown={onKeyDown}
        />
        <HelperText id={`${id}-hint`}>At least 20 characters. Press Ctrl+Enter or ⌘+Enter to send.</HelperText>
      </div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-6)' }}>
        <Button type='submit'>Send request</Button>
        <Text size={2} role='status' style={{ color: 'var(--label-secondary)' }}>{sent}</Text>
      </div>
    </form>
  );
}
```

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

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

Поле с `fullWidth` заполняет диалог, а `autoFocus` ставит в него курсор при открытии. Enter добавляет новую строку, а не отправляет форму; Escape по-прежнему закрывает диалог.

```tsx
import type { CSSProperties, FormEvent } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  HelperText,
  Icon,
  IconButton,
  Label,
  Text,
  TextArea,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';
import { useId, useState } from 'react';

export default function FeedbackInDialog() {
  const id = useId();
  const [status, setStatus] = useState('');

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    const feedback = String(new FormData(event.currentTarget).get('feedback'));
    setStatus(`Thanks! We received ${feedback.length} characters of feedback.`);
  };

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-6)', justifyItems: 'center', width: '100%' }}>
      <Dialog>
        <DialogTrigger asChild>
          <Button variant='secondary' appearance='ghost'>Send feedback</Button>
        </DialogTrigger>
        <DialogContent aria-labelledby={`${id}-title`} style={{ width: 'min(480px, 100%)' }}>
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
            <Heading id={`${id}-title`} as='h2' size={4} fontWeight={600}>Send feedback</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>
          <form method='dialog' onSubmit={onSubmit} style={{ display: 'grid', gap: 'var(--spacing-12)', marginBlockStart: 'var(--spacing-8)' }}>
            <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
              <Label htmlFor={id}>What could be better?</Label>
              <TextArea id={id} name='feedback' rows={6} fullWidth required autoFocus aria-describedby={`${id}-hint`} />
              <HelperText id={`${id}-hint`}>Escape closes the dialog; Enter adds a new line.</HelperText>
            </div>
            <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)' }}>
              <DialogClose asChild>
                <Button type='button' variant='secondary' appearance='ghost'>Cancel</Button>
              </DialogClose>
              <Button type='submit'>Send</Button>
            </div>
          </form>
        </DialogContent>
      </Dialog>
      <Text size={2} role='status' style={{ color: 'var(--label-secondary)', textAlign: 'center' }}>{status}</Text>
    </div>
  );
}
```

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

`size='sm'` и две строки оставляют второстепенное поле небольшим рядом с контентом, к которому оно относится. У `Label` такой же `size='sm'`.

```tsx
import { Button, Label, Text, TextArea } from '@vega-ui/react';
import { FormEvent, useId, useState } from 'react';

interface Note {
  id: number;
  text: string;
}

export default function CompactInternalNote() {
  const id = useId();
  const [notes, setNotes] = useState<Note[]>([
    { id: 1, text: 'Customer asked to move the renewal to March.' },
  ]);

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const form = event.currentTarget;
    const text = String(new FormData(form).get('note')).trim();
    if (text === '') return;

    setNotes((current) => [...current, { id: Date.now(), text }]);
    form.reset();
  };

  return (
    <div
      style={{
        display: 'grid',
        gap: 'var(--spacing-8)',
        width: '100%',
        padding: 'var(--spacing-8)',
        boxSizing: 'border-box',
        borderRadius: 'var(--radius-5)',
        border: '1px solid var(--separator-opaque)',
        background: 'var(--surface-primary)',
      }}
    >
      <ul style={{ display: 'grid', gap: 'var(--spacing-4)', margin: 0, padding: 0, listStyle: 'none' }}>
        {notes.map((note) => (
          <li key={note.id}><Text size={2} style={{ overflowWrap: 'anywhere' }}>{note.text}</Text></li>
        ))}
      </ul>
      <form onSubmit={onSubmit} style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
        <Label htmlFor={id} size='sm'>Internal note</Label>
        <TextArea id={id} name='note' size='sm' rows={2} fullWidth placeholder='Visible to your team only' />
        <Button type='submit' size='sm' variant='secondary' appearance='ghost' style={{ justifySelf: 'end', marginBlockStart: 'var(--spacing-1)' }}>Add note</Button>
      </form>
    </div>
  );
}
```

### Ошибка

#### Ошибка: мягкий лимит символов

В отличие от `maxLength`, мягкий лимит позволяет вставить длинный текст и потом сократить его. За пределом лимита поле получает `error` и `aria-invalid`, подсказка становится ошибкой, а отправка блокируется.

```tsx
import { Button, HelperText, Label, TextArea } from '@vega-ui/react';
import { FormEvent, useId, useState } from 'react';

const LIMIT = 160;

export default function SoftLimitError() {
  const id = useId();
  const messageId = `${id}-message`;
  const [bio, setBio] = useState(
    'Product designer at Northwind. I work on billing, onboarding and everything in between, and I spend weekends restoring old film cameras and hiking the coastal trails near Porto.',
  );
  const over = bio.length - LIMIT;
  const invalid = over > 0;

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
  };

  return (
    <form onSubmit={onSubmit} style={{ display: 'grid', gap: 'var(--spacing-12)', width: '100%' }}>
      <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
        <Label htmlFor={id}>Short bio</Label>
        <TextArea
          id={id}
          rows={6}
          fullWidth
          value={bio}
          error={invalid}
          aria-invalid={invalid}
          aria-describedby={messageId}
          onChange={(event) => setBio(event.currentTarget.value)}
        />
        <HelperText id={messageId} error={invalid}>
          {invalid ? `${over} characters over the ${LIMIT}-character limit.` : `${LIMIT - bio.length} characters left.`}
        </HelperText>
      </div>
      <Button type='submit' disabled={invalid} style={{ justifySelf: 'start' }}>Save bio</Button>
    </form>
  );
}
```

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

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

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

```tsx
import { Button, Fieldset, Label, Text, TextArea } from '@vega-ui/react';
import { FormEvent, useId, useState } from 'react';

export default function DisabledWhileSending() {
  const id = useId();
  const [sending, setSending] = useState(false);
  const [comments, setComments] = useState<string[]>([]);

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const form = event.currentTarget;
    const comment = String(new FormData(form).get('comment')).trim();
    if (comment === '') return;

    setSending(true);
    setTimeout(() => {
      setComments((current) => [...current, comment]);
      setSending(false);
      form.reset();
    }, 1200);
  };

  return (
    <form onSubmit={onSubmit} aria-busy={sending} style={{ display: 'grid', gap: 'var(--spacing-8)', width: '100%' }}>
      <Fieldset disabled={sending} style={{ display: 'grid', gap: 'var(--spacing-12)' }}>
        <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
          <Label htmlFor={id}>Comment</Label>
          <TextArea id={id} name='comment' rows={3} fullWidth placeholder='Reply to the reviewer' />
        </div>
        <Button type='submit' style={{ justifySelf: 'start' }}>{sending ? 'Posting…' : 'Post comment'}</Button>
      </Fieldset>
      <Text size={2} role='status' style={{ color: 'var(--label-secondary)' }}>
        {comments.length === 1 ? '1 comment posted' : `${comments.length} comments posted`}
      </Text>
    </form>
  );
}
```

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

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

`field-sizing: content` позволяет полю расти вместе с текстом, а `maxHeight` ограничивает рост — дальше появляется прокрутка. Браузеры без `field-sizing` сохраняют высоту из `rows`.

```tsx
import type { CSSProperties } from 'react';
import { HelperText, Label, TextArea } from '@vega-ui/react';
import { useId } from 'react';

export default function AutoGrowingNotes() {
  const id = useId();

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-3)', width: '100%' }}>
      <Label htmlFor={id}>Rollout notes</Label>
      <TextArea
        id={id}
        rows={2}
        fullWidth
        aria-describedby={`${id}-hint`}
        defaultValue={'Enable the new checkout for 10% of EU traffic on Monday.\nWatch the payment error rate for 24 hours before going to 50%.'}
        style={{ fieldSizing: 'content', maxHeight: 240, resize: 'none' } as CSSProperties}
      />
      <HelperText id={`${id}-hint`}>
        Grows with its content up to 240 px, then scrolls. Browsers without field-sizing keep two rows.
      </HelperText>
    </div>
  );
}
```

## Анатомия

### Обзор

`TextArea` — примитив из одной части, который сам рендерит нативный `<textarea>`. Каждый проп, который принимает нативный элемент, передаётся прямо в него, поэтому обёртки и дочерних частей нет.

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

#### `TextArea`

Обязательная. Нативный `<textarea>`: владеет значением, размером, стилями ошибки и полной ширины и принимает `name`, `rows`, `required`, `maxLength` и другие нативные атрибуты.

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

Экспортируемых дочерних частей нет. Обычно поле собирают из отдельных компонентов вокруг него:

#### `Label`

Необязательная, но нужна почти в любом интерфейсе. Называет textarea через `htmlFor` и его `id`.

#### `HelperText`

Необязательная. Подсказка или сообщение об ошибке, связанные с textarea через `aria-describedby`.

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

1. `Label`
2. `TextArea`
3. необязательный `HelperText` или счётчик

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

```tsx
import { HelperText, Label, TextArea } from '@vega-ui/react';
import { useId } from 'react';

export default function IssueDescription() {
  const id = useId();
  const hintId = `${id}-hint`;

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-3)', width: '100%' }}>
      <Label htmlFor={id}>What went wrong?</Label>
      <TextArea
        id={id}
        name='description'
        rows={4}
        fullWidth
        placeholder='Steps to reproduce, what you expected and what happened instead'
        aria-describedby={hintId}
      />
      <HelperText id={hintId}>Include the page URL and the time it happened, if you know it.</HelperText>
    </div>
  );
}
```

`Label` указывает на textarea по `id`, а подсказка под ним связана через `aria-describedby`. Textarea сам является элементом формы, поэтому `name`, `rows` и `fullWidth` задаются прямо на нём.

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

#### Дочерние элементы внутри `TextArea`

`<textarea>` берёт текст из `value` или `defaultValue`. Дочерние элементы — неподдерживаемый способ задать содержимое или добавить иконки и кнопки внутрь поля.

#### Обёртка в `TextField`

`TextField` ожидает `TextFieldInput` и рассчитан на однострочный `<input>`. Для многострочного текста используйте `TextArea` отдельно.

#### Плейсхолдер вместо подписи

Плейсхолдер исчезает, как только пользователь начинает печатать, поэтому он не может заменить `Label`.
