Создание переиспользуемых элементов управления форм с помощью Web Components
Создайте переиспользуемый элемент формы с Web Components и ElementInternals. Добавьте нативные отправку, валидацию, метки, сброс и восстановление.
Пользовательский элемент, оборачивающий <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
Discover how at OpenReplay.com.
Минимально полезный элемент управления объявляет 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 автоматически применяется к элементу при установке любого флага. Передача пустого объекта {} сбрасывает все ошибки.
Флаги соответствуют нативным причинам невалидности один к одному:
| Флаг | Аналог нативного триггера |
|---|---|
valueMissing | required без значения |
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 у хоста или предка fieldset | boolean | отражение на внутренний 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, когда элемент управления должен участвовать в отправке.
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