12k
All articles

Создание переиспользуемых элементов управления форм с помощью Web Components

Создайте переиспользуемый элемент формы с Web Components и ElementInternals. Добавьте нативные отправку, валидацию, метки, сброс и восстановление.

OpenReplay Team
OpenReplay Team
Создание переиспользуемых элементов управления форм с помощью Web Components

Пользовательский элемент, оборачивающий <input> внутри Shadow DOM, невидим для родительской <form>: его значение никогда не попадает в FormData, нативная валидация его игнорирует, а сброс формы не затрагивает его. Решение занимает две строки — static formAssociated = true и this.attachInternals() в конструкторе — и API ElementInternals, который открывают эти строки. В этой статье мы создадим полноценный элемент управления <text-field>, который отправляет данные, проходит валидацию, поддерживает метки, сброс и восстановление состояния — как нативный input, используя только платформенный API.

С выходом Safari 16.4 в марте 2023 года web-компоненты достигли важного рубежа в возможности взаимодействия с элементом формы через API ElementInternals; до этого элементы input, расположенные внутри Shadow DOM, были недоступны для форм, что означало отсутствие валидации при отправке и исключение их данных из объекта FormData. Вопрос поддержки теперь закрыт, поэтому статья пропускает рассуждения о совместимости и сосредотачивается на полном рецепте, который ни один из старых руководств не собирает воедино.

Ключевые выводы

  • Две строки превращают любой автономный пользовательский элемент в элемент управления формой: static formAssociated = true объявляет намерение, а this.attachInternals() возвращает объект ElementInternals, связывающий ваш элемент с родительской формой.
  • По состоянию на сентябрь 2025 года, form-associated custom elements входят в Baseline Widely Available — поддерживаются в Chrome начиная с версии 77, Edge с 79, Firefox с 98 и Safari с 16.4 — поэтому оговорка «а поддерживается ли это?» больше не актуальна.
  • Значение элемента управления попадает в FormData только при вызове internals.setFormValue(value); передача null полностью исключает элемент из отправки.
  • setValidity() блокирует отправку и применяет :invalid, но доступная валидация также требует видимого сообщения, связанного через aria-describedby — иначе форма молча отказывается отправляться.
  • Из двух режимов восстановления состояния, определённых в спецификации, только "restore" надёжно срабатывает в реальных браузерах; "autocomplete" задан в спецификации, но не реализован стабильно.

Почему Shadow DOM нарушает нативное участие в форме

Нативный <input> участвует в форме только тогда, когда он является потомком <form> в одном DOM-дереве. Shadow DOM — это отдельное дерево, поэтому input, отрендеренный внутри shadow root пользовательского элемента, структурно невидим для формы: браузер никогда не добавляет его в form.elements, не собирает его значение в FormData и не выполняет проверку ограничений против него. Это ожидаемый компромисс инкапсуляции — и именно та проблема, для решения которой существуют form-associated custom elements (FACE).

Платформенный ответ — ElementInternals. Метод HTMLElement.attachInternals() возвращает объект ElementInternals; этот метод позволяет пользовательскому элементу участвовать в HTML-формах, а интерфейс предоставляет инструменты для работы с этими элементами так же, как с любым стандартным элементом формы HTML, а также открывает доступ к Accessibility Object Model. FACE больше не является экспериментальной возможностью: form-associated custom elements входят в Baseline Widely Available с 27 сентября 2025 года, поддерживаются в Chrome 77 (2019), Edge 79 (2020), Firefox 98 (2022) и Safari 16.4, выпущенном 27 марта 2023 года. Полифилл element-internals-polyfill от сообщества по-прежнему существует для устаревших движков, но при наличии поддержки Baseline во всех современных браузерах большинство проектов в нём больше не нуждаются.

Одно жёсткое ограничение: добавление статического свойства formAssociated со значением true превращает автономный пользовательский элемент в form-associated custom element. Элемент должен напрямую расширять HTMLElement — не встроенный подкласс — а attachInternals() выбрасывает NotSupportedError, если элемент не является пользовательским.

Минимальный form-associated custom element

Минимально полезный элемент управления объявляет formAssociated, получает свои internals и передаёт значение в форму через сеттер. Всё остальное строится на этом каркасе.

class TextField extends HTMLElement {
  static formAssociated = true;
  static observedAttributes = ['value', 'required'];

  #internals;
  #value = '';

  constructor() {
    super();
    this.#internals = this.attachInternals();
  }

  get value() { return this.#value; }
  set value(v) {
    this.#value = v ?? '';
    this.#internals.setFormValue(this.#value);
  }

  get form() { return this.#internals.form; }
  get name() { return this.getAttribute('name'); }
  get type() { return this.localName; }
}

customElements.define('text-field', TextField);

setFormValue() — ключевой вызов здесь. Значение элемента управления попадает в FormData только при вызове internals.setFormValue(value); передайте null — и элемент полностью исключается из отправки. Паттерн с сеттером гарантирует, что каждое присвоение .value — из скрипта, атрибута или пользовательского ввода — повторно синхронизирует отправляемое значение. После ассоциации элемент ведёт себя как встроенный: коллекция elements формы включает все <button>, <fieldset>, <input>, <object>, <output>, <select>, <textarea> и form-associated custom elements, связанные с формой.

Если вам нужно лишь добавить произвольные данные в отправку — не создавая переиспользуемый элемент управления — достаточно более лёгкого события formdata: подпишитесь на него у формы и вызовите event.formData.append(...). Обращайтесь к ElementInternals, когда создаёте элемент управления, который должен проходить валидацию, поддерживать метки, сброс и восстановление.

Рендеринг и синхронизация внутреннего input

Элементу управления нужен внутренний <input>, значение которого передаётся наружу через публичный сеттер, а события input/change повторно диспетчеризуются от хоста, чтобы слушатели на <text-field> вели себя так же, как на нативном поле. Поскольку shadow root можно прикрепить только один раз, настройка защищена, чтобы disconnect-then-reconnect не вызвал attachShadow() повторно (что привело бы к ошибке):

connectedCallback() {
  if (!this.shadowRoot) {
    const root = this.attachShadow({ mode: 'open', delegatesFocus: true });
    root.innerHTML = `<input part="input" />`;
    this.input = root.querySelector('input');

    this.input.addEventListener('input', () => {
      this.value = this.input.value;        // повторная синхронизация setFormValue
      this.#validate();                      // валидация в реальном времени
    });
    this.input.addEventListener('change', (e) => {
      this.dispatchEvent(new e.constructor(e.type, e));
    });
  }

  this.value = this.getAttribute('value') ?? '';
  this.#validate();
}

Нативные события change не пересекают границу shadow, поэтому клонирование события с помощью new e.constructor(e.type, e) и повторная диспетчеризация от хоста переизлучает его в light DOM, где родительский код может его услышать. Управление this.value из слушателя input внутреннего input обеспечивает актуальность отправляемого значения в FormData при каждом нажатии клавиши.

Метки, делегирование фокуса и tabindex

Нативный <label for="..."> должен переводить фокус на элемент управления при клике. Для form-associated custom element эта связь обеспечивается параметром delegatesFocus: true на shadow root. Установка delegatesFocus: true — это то, что заставляет клик по <label for> перемещать фокус во внутренний input, точно как у нативного поля — без этого клик по метке ничего не делает, а фокусировка хоста не достигает input. Поскольку FACE является элементом с поддержкой меток, разметка пишется точно так же, как для нативного input:

<form>
  <label for="email">Email</label>
  <text-field id="email" name="email" required></text-field>
</form>

Ассоциация меток для вспомогательных технологий у пользовательских элементов управления исторически была непоследовательной в различных комбинациях браузеров и вспомогательных технологий, поэтому тестируйте на целевых AT и держите запасной <label> внутри shadow, если поддерживаете широкую матрицу. При установленном delegatesFocus управлять tabindex вручную, как правило, не нужно — фокус делегируется внутреннему input, который уже находится в порядке табуляции.

Валидация с setValidity() и полным ValidityStateFlags

ElementInternals.setValidity() отражает нативную систему проверки ограничений. Вы передаёте объект ValidityStateFlags, необязательное читаемое сообщение и необязательный элемент-якорь; браузер затем применяет псевдокласс :invalid и блокирует отправку. При такой настройке псевдокласс :invalid автоматически применяется к элементу при установке любого флага. Передача пустого объекта {} сбрасывает все ошибки.

Флаги соответствуют нативным причинам невалидности один к одному:

ФлагАналог нативного триггера
valueMissingrequired без значения
typeMismatchзначение не соответствует type (например, email)
patternMismatchзначение не проходит pattern
tooLong / tooShortпревышает maxlength / меньше minlength
rangeUnderflow / rangeOverflowниже min / выше max
stepMismatchзначение не выровнено по step
badInputнепарсируемый ввод
customErrorошибка, определённая автором через пользовательское сообщение

Выполняйте валидацию при каждом событии input для обратной связи в реальном времени и предоставляйте стандартные методы (checkValidity(), reportValidity(), validity, validationMessage), чтобы вызывающий код мог обращаться с вашим элементом управления как с любым нативным полем:

#validate() {
  const flags = {};
  let message = '';
  if (this.hasAttribute('required') && !this.#value) {
    flags.valueMissing = true;
    message = 'This field is required.';
  }
  this.#internals.setValidity(flags, message, this.input);
  this.#renderError(message);
}

checkValidity() { return this.#internals.checkValidity(); }
reportValidity() { return this.#internals.reportValidity(); }
get validity() { return this.#internals.validity; }
get validationMessage() { return this.#internals.validationMessage; }

setValidity() — лишь половина доступной валидации: браузер заблокирует отправку и применит :invalid, но если вы не рендерите видимое сообщение, связанное через aria-describedby, пользователь получит форму, которая молча отказывается отправляться. Шаг #renderError рассматривается в разделе о доступности.

Четыре колбэка жизненного цикла формы

Form-associated custom elements получают четыре дополнительных колбэка помимо стандартного жизненного цикла пользовательских элементов. Это та часть, которую большинство руководств пропускает. formAssociatedCallback(form) вызывается при изменении связанной формы; formResetCallback() — при сбросе формы, когда элемент должен очистить значение, установленное пользователем; formDisabledCallback(isDisabled) — при изменении состояния disabled; а formStateRestoreCallback(state, reason) — когда браузер восстанавливает состояние элемента (причина "restore") или выполняет автозаполнение (причина "autocomplete").

КолбэкСрабатывает когдаАргументТипичное тело
formAssociatedCallbackэлемент получает/теряет владельца формыновая form (или null)настройка состояния, зависящего от формы
formDisabledCallbackпереключается disabled у хоста или предка fieldsetbooleanотражение на внутренний input
formResetCallbackформа сбрасываетсянетвосстановление значения по умолчанию
formStateRestoreCallbackнавигация назад/вперёд, перезагрузка, автозаполнениевосстановленное состояние, причинарегидратация из сохранённого состояния
formDisabledCallback(disabled) {
  this.input.disabled = disabled;
}

formResetCallback() {
  this.value = this.getAttribute('value') ?? '';
  this.#internals.setValidity({});
  this.#renderError('');
}

formStateRestoreCallback(state, reason) {
  if (reason === 'restore') this.value = state;
}

Атрибуты disabled и readonly несут здесь специфическое значение по спецификации: атрибут disabled делает form-associated custom element неинтерактивным и предотвращает отправку его значения, тогда как атрибут readonly указывает, что элемент исключён из проверки ограничений.

Восстановление состояния по спецификации

setFormValue() принимает необязательный второй аргумент — setFormValue(value, state) — где state — это клиентское представление, которое не отправляется на сервер; оно полезно для восстановления более богатого внутреннего состояния, чем просто отправляемое значение. Спецификация теперь явно определяет, что получает колбэк восстановления: когда пользовательский агент обновляет значение form-associated custom element от имени пользователя или в рамках навигации, вызывается его formStateRestoreCallback с новым состоянием и строкой, указывающей причину — "autocomplete" или "restore". Более ранняя несогласованность спецификации привела к реальному расхождению — Chromium вызывал колбэк со значением, тогда как WebKit — с состоянием — но это было разрешено в пользу состояния. Если вы поддерживаете более старые сборки Chromium/Edge, обрабатывайте аргумент защитно.

Сегодня надёжен только "restore". Firefox добавил поддержку formStateRestoreCallback, но отмечает, что 'autocomplete' для пользовательских элементов по-прежнему не поддерживается. Рассматривайте "restore" (навигация назад и перезагрузка) как единственный режим, на который можно рассчитывать во всех движках.

Правильная реализация доступности

Установка валидности — это не то же самое, что её коммуникация. Чтобы ошибки были воспринимаемы, рендерите сообщение внутри shadow root, связывайте его с внутренним input через aria-describedby и помечайте его как живой регион, чтобы вспомогательные технологии его озвучивали:

#renderError(message) {
  let err = this.shadowRoot.querySelector('.error');
  if (!err) {
    err = document.createElement('div');
    err.className = 'error';
    err.id = 'err';
    err.setAttribute('role', 'alert');
    err.setAttribute('aria-live', 'assertive');
    this.shadowRoot.append(err);
    this.input.setAttribute('aria-describedby', 'err');
  }
  err.textContent = message;
}

Поскольку и input, и сообщение об ошибке находятся в одном shadow root, aria-describedby корректно разрешается внутри этого дерева. Собственная ARIA-семантика хост-элемента может быть задана через свойства ElementInternals — и эта возможность теперь широко поддерживается, включая ElementInternals.role и свойства отражения aria*.

Типичный сценарий производственного сбоя — наполовину готовый элемент управления, который выглядит завершённым, но молча не работает — и именно такие баги выявляет воспроизведение сессий, поскольку они не выбрасывают ошибок в консоль. Воспроизведение показывает пользователя, многократно нажимающего «Отправить» для элемента управления, сообщающего valueMissing без видимого сообщения; клик по метке, который никуда не ведёт, потому что delegatesFocus так и не был установлен; или отправленные данные без поля, потому что сеттер никогда не вызывал setFormValue. Ещё одна ловушка, достойная подсказки при ревью: слотирование нативного required input в пользовательский элемент оставляет light-DOM элемент активным в форме наряду с хостом, создавая двойное участие. Держите нативные элементы управления вне слотов, когда хост сам является form-associated.

Использование внутри фреймворка

Интеграции фреймворков для form-associated custom elements оборачивают именно тот сырой API ElementInternals, который показан здесь. Stencil предоставляет первоклассную поддержку начиная с v4.5.0 через декоратор @AttachInternals, а его formStateRestoreCallback также получает второй аргумент mode"restore" или "autocomplete" — указывающий причину восстановления. Lit не включает FACE по умолчанию — вы добавляете небольшой миксин FormAssociated самостоятельно или импортируете его из сообщества — но этот миксин оборачивает те же флаг formAssociated, attachInternals(), setFormValue() и колбэки жизненного цикла, показанные здесь. Прямое изучение платформенного API означает, что вы сможете читать, отлаживать или заменять любую из этих обёрток.

Соберите все части воедино — сеттер значения, синхронизацию внутреннего input, delegatesFocus, setValidity со связанным сообщением role="alert" и все четыре колбэка жизненного цикла — и вы получите единый <text-field>, который отправляет данные, проходит валидацию, сбрасывается и восстанавливается неотличимо от нативного input. Поместите его в <form>, подключите <label for>, и следующий конкретный шаг — проверить полный цикл: отправьте форму, прочитайте значение из new FormData(form) и убедитесь, что проверка ограничений блокирует пустое обязательное поле.

Часто задаваемые вопросы

В чём разница между событием formdata и ElementInternals для добавления значений в форму?

Событие formdata добавляет произвольные данные в отправку: вы подписываетесь на него у формы и вызываете event.formData.append() для добавления полей. ElementInternals создаёт переиспользуемый элемент управления, который полноценно участвует в форме, включая проверку ограничений, метки, сброс и восстановление состояния через setFormValue и setValidity. Используйте событие formdata для быстрого разового добавления данных; обращайтесь к ElementInternals, когда нужен элемент управления, ведущий себя как нативный input.

Почему attachInternals() выбрасывает NotSupportedError?

attachInternals() выбрасывает NotSupportedError, когда элемент не является автономным пользовательским элементом. Form-associated custom elements должны напрямую расширять HTMLElement, а не встроенный подкласс вроде HTMLInputElement, и класс должен быть зарегистрирован через customElements.define до создания экземпляра. Повторный вызов attachInternals() на том же элементе также вызывает ошибку. Размещайте вызов один раз в конструкторе класса, расширяющего HTMLElement, чтобы избежать ошибки.

Мешает ли delegatesFocus ручному управлению tabindex на form-associated custom element?

При установке delegatesFocus в true на shadow root управлять tabindex вручную, как правило, не нужно. Фокус делегируется первому фокусируемому элементу внутри shadow root — внутреннему input, уже находящемуся в порядке табуляции — поэтому хост-элемент автоматически перенаправляет фокус. Клик по label-for и навигация с клавиатуры оба достигают внутреннего input. Добавление ручного tabindex на хост может создать дублирующийся tab stop, поэтому опускайте его, когда delegatesFocus управляет маршрутизацией.

Что происходит при вызове setFormValue с null?

Вызов internals.setFormValue(null) полностью исключает элемент из отправки формы: его имя и значение исключаются из объекта FormData, который производит форма. Это отличается от передачи пустой строки, которая отправляет поле с пустым значением. Используйте null для намеренного исключения элемента управления, например отключённого или доступного только для чтения, и передавайте реальную строку значения или FormData, когда элемент управления должен участвовать в отправке.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.