Перейти к содержимому
v3.0.0
English
Русский
Документация

TextField

TextField — составной компонент для однострочного ввода текста.
text-field.tsx · 8 строк
import { Label, TextField, TextFieldInput } from '@vega-ui/react';

<div>
  <Label htmlFor='preview-email'>Email</Label>
  <TextField>
    <TextFieldInput id='preview-email' type='email' placeholder='name@company.com' />
  </TextField>
</div>
Поддержка браузеров
Широко доступно
  • Chrome 111
  • Edge 111
  • Firefox 121
  • Safari 15.4
  • Safari на iOS 15.4
Размер в бандле
История версий
Добавлен в v1.0.0, последнее изменение в v3.0.0

Кратко

TextField — составной компонент для однострочного ввода текста. Корень задаёт общий размер и состояние ошибки, а TextFieldInput владеет нативным элементом ввода. Это базовая обёртка однострочного поля, на которой строятся другие специализированные поля ввода текста.

Импорт

import {
  TextField,
  TextFieldInput,
  type TextFieldInputProps,
  type TextFieldProps,
  type TextFieldSize,
} from '@vega-ui/react';

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

  • TextFieldProps
  • TextFieldInputProps
  • TextFieldSize = 'sm' | 'md' | 'lg' | string

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

work-email-field.tsx · 24 строки
import { HelperText, Label, TextField, TextFieldInput } from '@vega-ui/react';
import { useId } from 'react';

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

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-3)', width: '100%' }}>
      <Label htmlFor={id}>Work email</Label>
      <TextField>
        <TextFieldInput
          id={id}
          type='email'
          name='email'
          autoComplete='email'
          placeholder='maria.lopez@northwind.io'
          aria-describedby={hintId}
        />
      </TextField>
      <HelperText id={hintId}>Invoices and security alerts are sent here.</HelperText>
    </div>
  );
}

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

  • TextField: обёртка раскладки с контекстом размера и ошибки
  • TextFieldInput: нативный элемент ввода

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

  • дочерние элементы префикса или суффикса: Button, IconButton, счётчики или текст статуса

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

  1. TextField
  2. необязательный дочерний элемент в начале
  3. TextFieldInput
  4. необязательный дочерний элемент в конце

Варианты

  • Размер: sm, md, lg
  • Состояние: по умолчанию, error, отключённый инпут
  • Композиция: простой инпут, элементы управления в префиксе и суффиксе, контролируемое или неконтролируемое значение

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

  • TextField сам по себе не инпут; атрибуты формы задаются на TextFieldInput.
  • Действия в префиксе и суффиксе меняют область нажатия и плотность раскладки, поэтому добавляйте их осознанно.
  • Визуальная ошибка задаётся на обёртке, но семантика ввода по-прежнему принадлежит нативному инпуту.
  • Когда маска, показ пароля, числовой шаг или форматирование телефона становятся частью контракта поля, специализированное поле обычно понятнее, чем растягивание TextField.

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

  • Передача value, onChange или name в TextField вместо TextFieldInput.
  • Рендер обёртки без TextFieldInput.
  • Отношение к дочерним элементам префикса и суффикса как к декоративным, хотя они меняют поведение взаимодействия.

Примеры

Базовый

Базовый: рабочий email с подсказкой

Видимый Label указывает на TextFieldInput через htmlFor, а подсказка связана через aria-describedby. Все атрибуты инпута — type, name, autoComplete — ставятся на TextFieldInput, а не на обёртку. Id берутся из useId, поэтому несколько полей на одной странице не конфликтуют.

work-email-field.tsx · 24 строки
import { HelperText, Label, TextField, TextFieldInput } from '@vega-ui/react';
import { useId } from 'react';

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

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-3)', width: '100%' }}>
      <Label htmlFor={id}>Work email</Label>
      <TextField>
        <TextFieldInput
          id={id}
          type='email'
          name='email'
          autoComplete='email'
          placeholder='maria.lopez@northwind.io'
          aria-describedby={hintId}
        />
      </TextField>
      <HelperText id={hintId}>Invoices and security alerts are sent here.</HelperText>
    </div>
  );
}

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

Контролируемый/с состоянием: счётчик символов в суффиксе

Значением владеет родитель, поэтому счётчик в суффиксе и превью ниже обновляются с каждым нажатием. maxLength останавливает ввод на лимите, а счётчик связан с инпутом через aria-describedby.

character-counter-suffix.tsx · 36 строк
import { Label, Text, TextField, TextFieldInput } from '@vega-ui/react';
import { useId, useState } from 'react';

const MAX_LENGTH = 60;

export default function CharacterCounterSuffix() {
  const id = useId();
  const counterId = `${id}-counter`;
  const [title, setTitle] = useState('Q3 roadmap review');
  const remaining = MAX_LENGTH - title.length;

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-3)', width: '100%' }}>
      <Label htmlFor={id}>Meeting title</Label>
      <TextField>
        <TextFieldInput
          id={id}
          value={title}
          maxLength={MAX_LENGTH}
          aria-describedby={counterId}
          onChange={(event) => setTitle(event.currentTarget.value)}
        />
        <Text
          id={counterId}
          size={2}
          style={{ paddingInlineEnd: 'var(--spacing-6)', whiteSpace: 'nowrap', color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums' }}
        >
          {remaining} left
        </Text>
      </TextField>
      <Text size={2} style={{ color: 'var(--label-secondary)', overflowWrap: 'anywhere' }}>
        Calendar preview: {title.trim() === '' ? 'Untitled meeting' : title.trim()}
      </Text>
    </div>
  );
}

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

Форма/интеграция: форма профиля и FormData

Неконтролируемые инпуты с name и defaultValue читаются через FormData при отправке. required и type='email' включают нативную валидацию, а кнопка сброса возвращает значения по умолчанию.

profile-form-submit.tsx · 39 строк
import { Button, Label, Text, TextField, TextFieldInput } from '@vega-ui/react';
import { FormEvent, useId, useState } from 'react';

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

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setSaved(`${data.get('fullName')} <${data.get('email')}>`);
  };

  return (
    <form
      onSubmit={onSubmit}
      onReset={() => setSaved('')}
      style={{ display: 'grid', gap: 'var(--spacing-8)', width: '100%' }}
    >
      <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
        <Label htmlFor={`${id}-name`}>Full name</Label>
        <TextField>
          <TextFieldInput id={`${id}-name`} name='fullName' autoComplete='name' defaultValue='Ada Lovelace' required />
        </TextField>
      </div>
      <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
        <Label htmlFor={`${id}-email`}>Email</Label>
        <TextField>
          <TextFieldInput id={`${id}-email`} name='email' type='email' autoComplete='email' required />
        </TextField>
      </div>
      <div style={{ display: 'flex', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-4)' }}>
        <Button type='submit'>Save profile</Button>
        <Button type='reset' variant='secondary' appearance='ghost'>Reset</Button>
      </div>
      <Text size={2} role='status' style={{ color: 'var(--label-secondary)', overflowWrap: 'anywhere' }}>{saved === '' ? '' : `Saved: ${saved}`}</Text>
    </form>
  );
}

Форма/интеграция: поиск с очисткой и отправкой

Форма с role='search': иконка в начале, кнопка очистки, которая возвращает фокус в инпут, и кнопка отправки — всё внутри одного TextField. Видимого лейбла нет, поэтому у инпута есть aria-label.

search-with-clear-and-submit.tsx · 57 строк
import type { CSSProperties, FormEvent } from 'react';
import { Button, Icon, IconButton, Text, TextField, TextFieldInput } from '@vega-ui/react';
import { SearchIcon, XIcon } from '@vega-ui/icons';
import { useRef, useState } from 'react';
import './examples.css';

export default function SearchWithClearAndSubmit() {
  const inputRef = useRef<HTMLInputElement>(null);
  const [query, setQuery] = useState('');
  const [searched, setSearched] = useState('');

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

  const clear = () => {
    setQuery('');
    inputRef.current?.focus();
  };

  return (
    <form role='search' onSubmit={onSubmit} style={{ display: 'grid', gap: 'var(--spacing-3)', width: '100%' }}>
      <TextField>
        <Icon size='sm' aria-hidden style={{ flexShrink: 0, marginInlineStart: 'var(--spacing-6)', color: 'var(--label-secondary)' }}>
          <SearchIcon />
        </Icon>
        <TextFieldInput
          ref={inputRef}
          className='searchInput'
          type='search'
          name='q'
          aria-label='Search invoices'
          placeholder='Invoice or customer'
          value={query}
          onChange={(event) => setQuery(event.currentTarget.value)}
        />
        {query !== '' && (
          <IconButton
            type='button'
            aria-label='Clear search'
            variant='secondary'
            appearance='ghost'
            onClick={clear}
            style={{ '--icon-button-size': '24px', '--icon-button-br-ratio': 0.5, flexShrink: 0 } as CSSProperties}
          >
            <Icon size='xs'><XIcon strokeWidth={2.5} /></Icon>
          </IconButton>
        )}
        <Button type='submit' size='sm' style={{ flexShrink: 0, marginInlineStart: 'var(--spacing-2)', marginInlineEnd: 'var(--spacing-3)' }}>Search</Button>
      </TextField>
      <Text size={2} role='status' style={{ color: 'var(--label-secondary)', overflowWrap: 'anywhere' }}>
        {searched === '' ? 'Showing all invoices' : `Results for “${searched}”`}
      </Text>
    </form>
  );
}

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

Раскладка/оверлей: переименование в диалоге

Форма с method='dialog' закрывает диалог при отправке, а onSubmit читает новое имя через FormData. autoFocus ставит курсор в поле сразу после открытия диалога.

rename-in-dialog.tsx · 67 строк
import type { CSSProperties, FormEvent } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  Icon,
  IconButton,
  Label,
  Text,
  TextField,
  TextFieldInput,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';
import { useId, useState } from 'react';

export default function RenameInDialog() {
  const id = useId();
  const [name, setName] = useState('Mobile app redesign');

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    const next = String(new FormData(event.currentTarget).get('projectName')).trim();
    if (next !== '') setName(next);
  };

  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-6)', width: '100%' }}>
      <Text size={3} fontWeight={500} style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{name}</Text>
      <Dialog>
        <DialogTrigger asChild>
          <Button size='sm' variant='secondary' appearance='ghost'>Rename</Button>
        </DialogTrigger>
        <DialogContent aria-labelledby={`${id}-title`} style={{ width: 'min(400px, 100%)' }}>
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
            <Heading id={`${id}-title`} as='h2' size={4} fontWeight={600}>Rename project</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}-name`}>Project name</Label>
              <TextField>
                <TextFieldInput id={`${id}-name`} name='projectName' defaultValue={name} maxLength={80} required autoFocus />
              </TextField>
            </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'>Save</Button>
            </div>
          </form>
        </DialogContent>
      </Dialog>
    </div>
  );
}

Ошибка

Ошибка: проверка email при потере фокуса и отправке

Ошибка появляется при потере фокуса или отправке и, пока видна, перепроверяется на каждое нажатие — поэтому исчезает, как только адрес становится корректным. error только красит обёртку; состояние до скринридера доносят aria-invalid и aria-describedby на инпуте.

email-validation-error.tsx · 50 строк
import { Button, HelperText, Label, TextField, TextFieldInput } from '@vega-ui/react';
import { FormEvent, useId, useState } from 'react';

const getEmailError = (value: string) => {
  if (value.trim() === '') return 'Enter an email address to send the invite.';
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return 'Use the full address, like dana@northwind.io.';
  return '';
};

export default function EmailValidationError() {
  const id = useId();
  const messageId = `${id}-message`;
  const [email, setEmail] = useState('dana@northwind');
  const [error, setError] = useState('');

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

  const onChange = (value: string) => {
    setEmail(value);
    if (error !== '') setError(getEmailError(value));
  };

  const invalid = error !== '';

  return (
    <form noValidate onSubmit={onSubmit} style={{ display: 'grid', gap: 'var(--spacing-12)', width: '100%' }}>
      <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
        <Label htmlFor={id}>Invite a teammate</Label>
        <TextField error={invalid}>
          <TextFieldInput
            id={id}
            type='email'
            value={email}
            aria-invalid={invalid}
            aria-describedby={messageId}
            onChange={(event) => onChange(event.currentTarget.value)}
            onBlur={() => setError(getEmailError(email))}
          />
        </TextField>
        <HelperText id={messageId} error={invalid}>
          {invalid ? error : 'We will email them a link to join the workspace.'}
        </HelperText>
      </div>
      <Button type='submit' style={{ justifySelf: 'start' }}>Send invite</Button>
    </form>
  );
}

Отключённый

Отключённый: только для чтения и отключённое поле

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

read-only-and-disabled.tsx · 46 строк
import { Button, HelperText, Label, Text, TextField, TextFieldInput } from '@vega-ui/react';
import { FormEvent, useId, useState } from 'react';

export default function ReadOnlyAndDisabled() {
  const id = useId();
  const [submitted, setSubmitted] = useState('');

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const keys = Array.from(new FormData(event.currentTarget).keys());
    setSubmitted(keys.join(', '));
  };

  return (
    <form onSubmit={onSubmit} style={{ display: 'grid', gap: 'var(--spacing-8)', width: '100%' }}>
      <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
        <Label htmlFor={`${id}-workspace`}>Workspace ID</Label>
        <TextField>
          <TextFieldInput
            id={`${id}-workspace`}
            name='workspaceId'
            defaultValue='ws_8f2c41d9'
            readOnly
            aria-describedby={`${id}-workspace-hint`}
          />
        </TextField>
        <HelperText id={`${id}-workspace-hint`}>Read-only: focusable, selectable and submitted.</HelperText>
      </div>
      <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
        <Label htmlFor={`${id}-billing`}>Billing email</Label>
        <TextField>
          <TextFieldInput
            id={`${id}-billing`}
            name='billingEmail'
            defaultValue='finance@northwind.io'
            disabled
            aria-describedby={`${id}-billing-hint`}
          />
        </TextField>
        <HelperText id={`${id}-billing-hint`}>Disabled: only the account owner can change it. Not submitted.</HelperText>
      </div>
      <Button type='submit' style={{ justifySelf: 'start', marginBlockStart: 'var(--spacing-4)' }}>Submit</Button>
      <Text size={2} role='status' style={{ color: 'var(--label-secondary)' }}>{submitted === '' ? '' : `Submitted fields: ${submitted}`}</Text>
    </form>
  );
}

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

Пограничный случай: асинхронная проверка доступности

Проверка запускается через 600 мс после последнего нажатия, а очистка эффекта отменяет устаревшие таймеры. Пока идёт проверка, в суффиксе крутится Spinner, а подсказка — вежливая live-область, поэтому результат озвучивается.

async-username-check.tsx · 60 строк
import { HelperText, Icon, Label, Spinner, TextField, TextFieldInput } from '@vega-ui/react';
import { CheckIcon } from '@vega-ui/icons';
import { useEffect, useId, useState } from 'react';

const TAKEN = ['admin', 'support', 'maria', 'billing'];

interface CheckResult {
  username: string;
  available: boolean;
}

export default function AsyncUsernameCheck() {
  const id = useId();
  const statusId = `${id}-status`;
  const [username, setUsername] = useState('');
  const [result, setResult] = useState<CheckResult | null>(null);
  const normalized = username.trim().toLowerCase();

  useEffect(() => {
    if (normalized.length < 3) return;

    const timer = setTimeout(() => {
      setResult({ username: normalized, available: !TAKEN.includes(normalized) });
    }, 600);

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

  const tooShort = normalized.length < 3;
  const checking = !tooShort && result?.username !== normalized;
  const taken = !tooShort && !checking && result?.available === false;
  const available = !tooShort && !checking && result?.available === true;

  let message = 'At least 3 characters. Try “maria” to see a taken name.';
  if (checking) message = 'Checking availability…';
  if (available) message = `@${normalized} is available.`;
  if (taken) message = `@${normalized} is already taken.`;

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-3)', width: '100%' }}>
      <Label htmlFor={id}>Username</Label>
      <TextField error={taken}>
        <TextFieldInput
          id={id}
          value={username}
          autoComplete='username'
          spellCheck={false}
          aria-invalid={taken}
          aria-describedby={statusId}
          onChange={(event) => setUsername(event.currentTarget.value)}
        />
        <span style={{ display: 'flex', paddingInlineEnd: 'var(--spacing-6)' }}>
          {checking && <Spinner size={3} aria-label='Checking username' />}
          {available && <Icon size='sm' aria-hidden><CheckIcon /></Icon>}
        </span>
      </TextField>
      <HelperText id={statusId} error={taken} aria-live='polite'>{message}</HelperText>
    </div>
  );
}

Пограничный случай: длинное значение между префиксом и суффиксом

Статичные аффиксы сохраняют ширину, а инпут сжимается (minWidth: 0) и прокручивает длинное значение. Аффиксы скрыты от скринридера, потому что подсказка проговаривает адрес целиком.

subdomain-prefix-suffix.tsx · 40 строк
import { HelperText, Label, Text, TextField, TextFieldInput } from '@vega-ui/react';
import { useId, useState } from 'react';

const affixStyle = {
  flexShrink: 0,
  whiteSpace: 'nowrap',
  color: 'var(--label-secondary)',
} as const;

export default function SubdomainPrefixSuffix() {
  const id = useId();
  const hintId = `${id}-hint`;
  const [subdomain, setSubdomain] = useState('northwind-traders-international-logistics');

  const onChange = (value: string) => {
    setSubdomain(value.toLowerCase().replace(/[^a-z0-9-]/g, ''));
  };

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-3)', width: '100%' }}>
      <Label htmlFor={id}>Workspace address</Label>
      <TextField>
        <Text size={3} aria-hidden style={{ ...affixStyle, paddingInlineStart: 'var(--spacing-6)' }}>https://</Text>
        <TextFieldInput
          id={id}
          name='subdomain'
          value={subdomain}
          spellCheck={false}
          aria-describedby={hintId}
          onChange={(event) => onChange(event.currentTarget.value)}
          style={{ minWidth: 0, paddingInline: 'var(--spacing-1)' }}
        />
        <Text size={3} aria-hidden style={{ ...affixStyle, paddingInlineEnd: 'var(--spacing-6)' }}>.vega.app</Text>
      </TextField>
      <HelperText id={hintId} style={{ overflowWrap: 'anywhere' }}>
        Lowercase letters, digits and hyphens. Full address: https://{subdomain === '' ? '…' : subdomain}.vega.app
      </HelperText>
    </div>
  );
}

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

При dir='rtl' иконка переезжает вправо, потому что поле использует логические отступы. Инпут email сохраняет dir='ltr': адреса всегда пишутся слева направо.

rtl-field.tsx · 31 строка
import { HelperText, Icon, Label, TextField, TextFieldInput } from '@vega-ui/react';
import { SearchIcon } from '@vega-ui/icons';
import { useId } from 'react';

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

  return (
    <div dir='rtl' lang='he' style={{ display: 'grid', gap: 'var(--spacing-8)', width: '100%' }}>
      <TextField>
        <Icon size='sm' aria-hidden style={{ flexShrink: 0, marginInlineStart: 'var(--spacing-6)', color: 'var(--label-secondary)' }}>
          <SearchIcon />
        </Icon>
        <TextFieldInput type='search' aria-label='חיפוש לקוחות' placeholder='שם לקוח או מספר הזמנה' />
      </TextField>
      <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
        <Label htmlFor={`${id}-email`}>דוא״ל לחשבוניות</Label>
        <TextField>
          <TextFieldInput
            id={`${id}-email`}
            type='email'
            dir='ltr'
            defaultValue='finance@northwind.co.il'
            aria-describedby={`${id}-hint`}
          />
        </TextField>
        <HelperText id={`${id}-hint`}>כתובות דוא״ל נכתבות תמיד משמאל לימין.</HelperText>
      </div>
    </div>
  );
}

Анатомия

Обзор

TextField — составной компонент с небольшим контрактом композиции. Обёртка координирует размер и визуальное состояние, а TextFieldInput предоставляет настоящий элемент формы.

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

TextField

Обязательная. Владеет общим размером, состоянием ошибки и визуальным контейнером.

TextFieldInput

Обязательная. Рендерит нативный <input> и получает настоящие пропсы инпута.

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

Дочерние элементы префикса или суффикса

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

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

  1. TextField
  2. необязательный дочерний элемент префикса
  3. TextFieldInput
  4. необязательный дочерний элемент суффикса

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

search-with-clear-and-submit.tsx · 57 строк
import type { CSSProperties, FormEvent } from 'react';
import { Button, Icon, IconButton, Text, TextField, TextFieldInput } from '@vega-ui/react';
import { SearchIcon, XIcon } from '@vega-ui/icons';
import { useRef, useState } from 'react';
import './examples.css';

export default function SearchWithClearAndSubmit() {
  const inputRef = useRef<HTMLInputElement>(null);
  const [query, setQuery] = useState('');
  const [searched, setSearched] = useState('');

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

  const clear = () => {
    setQuery('');
    inputRef.current?.focus();
  };

  return (
    <form role='search' onSubmit={onSubmit} style={{ display: 'grid', gap: 'var(--spacing-3)', width: '100%' }}>
      <TextField>
        <Icon size='sm' aria-hidden style={{ flexShrink: 0, marginInlineStart: 'var(--spacing-6)', color: 'var(--label-secondary)' }}>
          <SearchIcon />
        </Icon>
        <TextFieldInput
          ref={inputRef}
          className='searchInput'
          type='search'
          name='q'
          aria-label='Search invoices'
          placeholder='Invoice or customer'
          value={query}
          onChange={(event) => setQuery(event.currentTarget.value)}
        />
        {query !== '' && (
          <IconButton
            type='button'
            aria-label='Clear search'
            variant='secondary'
            appearance='ghost'
            onClick={clear}
            style={{ '--icon-button-size': '24px', '--icon-button-br-ratio': 0.5, flexShrink: 0 } as CSSProperties}
          >
            <Icon size='xs'><XIcon strokeWidth={2.5} /></Icon>
          </IconButton>
        )}
        <Button type='submit' size='sm' style={{ flexShrink: 0, marginInlineStart: 'var(--spacing-2)', marginInlineEnd: 'var(--spacing-3)' }}>Search</Button>
      </TextField>
      <Text size={2} role='status' style={{ color: 'var(--label-secondary)', overflowWrap: 'anywhere' }}>
        {searched === '' ? 'Showing all invoices' : `Results for “${searched}”`}
      </Text>
    </form>
  );
}

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

Обёртка без TextFieldInput

Компонент рендерится, но настоящего элемента формы нет.

Пропсы инпута переданы обёртке

Это ломает поведение формы, потому что обёртка — просто <div>.

Слишком много конкурирующих элементов управления вокруг инпута

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