javascript

Ultimate guide по веб-компонентам

  • воскресенье, 1 марта 2026 г. в 00:00:09
https://habr.com/ru/articles/994666/

Привет. Я фронтендер, и я... люблю веб-компоненты. Ещё меня расстраивает, когда в статьях о веб-компонентах упоминается connectedCallback(), и, может быть, shadowRoot, хотя возможности веб-компонентов куда шире, да и скучно читать пересказ документации. Хочу показать вам веб-компоненты с другой стороны, и эта статья — туториал, где мы пошагово реализуем сложный веб-компонент — <combo-box> — пройдя все восемь кругов... ну вы поняли. Шучу. На самом деле мы напишем мало кода, вопреки устоявшемуся мифу, что веб-компоненты состоят из бойлерплейта, а объем статьи обусловлен не количеством кода, а глубиной погружения в тему.

Навигация

Для начала определим основные требования к нашему компоненту:

  1. Должен работать с любым фреймворком (React, Vue и т.д.) и, разумеется, в ванильном JS.

  2. Должен быть интуитивно понятным и знакомым для разработчика.

  3. Должен быть для браузера стандартным элементом формы (как, например, <input> или <select>) и вести себя соответствующим образом.

Атрибуты и свойства

Очевидно, что нашему компоненту потребуются атрибуты, через которые пользователь сможет контролировать его поведение. Базовый минимум это requireddisabledplaceholder, но в нашем случае их будет больше. За каждым сильным атрибутом стоит умное свойство в экземпляре класса соответствующего элемента, в объектной модели документа, поэтому важно разобраться (ну или вспомнить), что такое «content vs IDL attributes» и как с этим работать.

В документации по веб-компонентам обычно упоминается такой способ работы с атрибутами:

class HTMLComboboxElement extends HTMLElement {
  // ...

  // список наблюдаемых атрибутов
  static observedAttributes = ['required', 'disabled'];
  
  attributeChangedCallback(name, oldValue, newValue) { 
    // как-то реагируем на изменение атрибута 
  }
  
  // ...
}

Но нам он не подходит, потому что attributeChangedCallback() уведомляет нас об уже совершённом действии – изменении атрибута. Но при парсинге HTML и формировании DOM-дерева браузер устанавливает атрибуты напрямую, а у каждого фреймворка свой взгляд на мир. Preact, например, для определенного списка имен, или если name in element вернет true, просто попытается присвоить значение через element.name = value вместо element.setAttribute(name, value). В этих случаях attributeChangedCallback() не сработает.

Так как нам нужно контролировать этот процесс полностью, то есть синхронизировать атрибуты со свойствами и нормализовывать значения, то мы вообще не будем использовать observedAttributes и attributeChangedCallback().

Вместо этого, мы переопределим методы setAttribute и removeAttribute из базового класса HTMLElement от которого наследуемся:

class HTMLComboboxElement extends HTMLElement {
  // ...
  
  setAttribute(name, value) { / ... / }

  removeAttribute(name) { / ... / }
  
  // ...
}

А для каждого нужного нам атрибута определим пару get/set:

class HTMLComboboxElement extends HTMLElement {
  // ...
  
  get disabled() { / ... / }
  set disabled(value) { / ... / }

  // ...
}

Такая конструкция необходима, потому что мы хотим управлять компонентом как через element.setAttribute(name, value), так и через element.name = value, и нормализация значений нужна в обоих случаях, потому что все наши любимые фреймво��ки — с сюрпризами! Представим себе такой псевдокод с JSX или его подобием:

let val = true; 

setTimeout(() => val = false, 1000)

<combo-box foo bar={val} />

Из JSX фреймворк создаст элемент, потом присвоит ему атрибуты foo и bar, но фреймворки делают это по-разному и некорректно. Давайте на примере Vue и React посмотрим с какими аргументами они вызовут setAttribute() для кода выше:

Vue:

  1. 'foo', '' – пустая строка (корректно);

  2. 'bar', true – булево значение (некорректно);
    через 1 секунду...

  3. 'bar', false булево значение (некорректно).

React:

  1. 'foo', 'true' – строка true (некорректно);

  2. 'bar', 'true' – также строка (некорректно);
    через 1 секунду...

  3. 'bar', 'false'строка false, вообще не годится!

Мы должны защититься от этого безобразия. Для начала нам нужен список наших атрибутов, чтобы в переопределённых методах setAttribute() и removeAttribute() проверять — наше ли это свойство:

class HTMLComboboxElement extends HTMLElement {
  
  static OWN_ATTRIBUTES = new Set([
    'disabled',
    // ... остальные
  ]);

  // ...
}

Проверка с оператором in не подходит, потому что он смотрит в прототип, и мы затронем атрибуты самого HTMLElement, а Object.hasOwn() не увидит наши свойства, потому что они определены как пары get/set в прототипе. Список надежнее.

Теперь в setAttribute() и removeAttribute() мы будем проверять, наш ��и это атрибут. Если да, то просто вызываем соответствующий сеттер, если нет, передаем управление родительскому методу через super (иначе вызовем сами себя):

class HTMLComboboxElement extends HTMLElement {
  // ...

  setAttribute(name, value) {
    if (HTMLComboboxElement.OWN_ATTRIBUTES.has(name)) {
      this[name] = value;
    } else {
      super.setAttribute(name, value);
    }
  }

  removeAttribute(name) {
    if (HTMLComboboxElement.OWN_ATTRIBUTES.has(name)) {
      this[name] = null;
    } else {
      super.removeAttribute(name);
    }
  }

  // ...
}

Сама реализация пар get/set проста. В get будем возвращать значение атрибута с помощью hasAttribute для булевых, или getAttribute для остальных, а в set будем нормализовывать значение и устанавливать соответствующий атрибут, также через super, иначе попадем в бесконечный цикл:

class HTMLComboboxElement extends HTMLElement {
  // ...

  get disabled() {
    return this.hasAttribute('disabled');
  }

  set disabled(value) {
    super.toggleAttribute('disabled', normalized(value))  
    // ...и, возможно, как-то реагируем компонентом на это изменение
  }

  // ...
}

Важно, что сеттер необязательно должен запускать сайд-эффекты, теперь для этого можно использовать attributeChangedCallback(). А если бы мы его использовали для нормализации значений, то у нас бы ничего не работало, потому что единственный способ нормализации был бы повторный вызов setAttribute() внутри attributeChangedCallback(), что привело бы к вызову attributeChangedCallback() снова, то есть к переполнению стека.

Последнее, но не менее важное — обеспечить изначальную синхронизацию и нормализацию значений. Напомню, что при инициализации из HTML (innerHTML, document.write, SSR) атрибуты устанавливаются браузером напрямую, а не через setAttribute(). Для этого подходит метод connectedCallback(), который как раз вызывается при вставке элемента в DOM:

connectedCallback() {
  // Синхронизируем только наши атрибуты
  for (const name of HTMLComboboxElement.OWN_ATTRIBUTES) {
    // Принудительно нормализуем начальное значение
    this[name] = this.getAttribute(name);
  }
}

Вот теперь мы реализовали надёжный механизм синхронизации атрибутов и свойств, который работает с любым фреймворком. Только после этого можем полагаться на attributeChangedCallback(), но для нашей задачи он вовсе не нужен.

Имитируем нативный элемент формы

Пока наш компонент с точки зрения браузера — не более чем странный <div>. Давайте превратим его в настоящий элемент формы. Для этого воспользуемся API ElementInternals:

class HTMLComboboxElement extends HTMLElement {
  // ...
  
  static formAssociated = true;
  internals;
  
  constructor() {
    super();
    this.internals = this.attachInternals();
  }

  // ...
}

Что мы сделали и для чего:

  1. Добавили статичное свойство formAssociated со значением true.
    Благодаря этому наш компонент становится «видимым» для API форм. Если наш элемент окажется внутри формы, браузер добавит его в коллекцию form.elements, учтёт при сбросе или отправке формы, а ссылка на родительскую форму будет нам доступна через this.internals.form.

  2. Получили экземпляр ElementInternals и присвоили его свойству internals.

Это уже дало нам возможность стилизовать компонент с помощью псевдоклассов типа :disabled или :required, причём снаружи (из Light DOM), но мы можем больше! Выставив внутренности ElementInternals наружу через публичный API, мы дадим разработчику полный доступ к Constraint Validation нашего компонента:

class HTMLComboboxElement extends HTMLElement {
  // ...

  get validity() {
    return this.internals.validity;
  }

  get willValidate() {
    return this.internals.willValidate;
  }
  
  reportValidity() {
    this.internals.reportValidity();
  }

  checkValidity() {
    this.internals.checkValidity();
  }

  setCustomValidity(message) {
    if (message === '') {
      this.internals.setValidity({});
    } else {
      this.internals.setValidity({ customError: true }, message);
    }
  }

  // ...
}

Далее, каждый раз когда value компонента меняется, мы должны установить корректный validityState, и положить value в форму:

class HTMLComboboxElement extends HTMLElement {
  // ...

  #onChange(value) {
    // ...
    
    // передаем value в форму
    this.internals.setFormValue(value);
  
    // меняем validity state
    if (this.required && value.length === 0) {
      this.internals.setValidity({ valueMissing: true });
    } else {
      this.internals.setValidity({ });
    }
    
    // ...
  }

  // ...
}

Теперь такие псевдоклассы как :invalid или :required будут отражать реальное состояние компонента, а если он внутри формы, то корректно сформируется FormData:

function Example() {
  
  const onSubmit = event => {
    const formData = new FormData(event.target);
    console.log(formData.get("bar")) // world
  }
  
  return (
    <form onSubmit={onSubmit}>
      <input name="foo" value="hello" />
      <combo-box name="bar" value="world" />
    </form>
  )
}

Теперь нам нужно реализовать обратную связь с формой (если она есть). Для этого реализуем методы formResetCallback() и formDisabledCallback(). Эти методы вызываются самой формой, они — часть Web Components API, но по какой-то причине не задокументированы:

class HTMLComboboxElement extends HTMLElement {
  // ...

  // Пользователь сбросил форму,
  // например нажав на <button type="reset" />
  formResetCallback() {
    this.#onChange('')
  }

  // Вызывается после изменения атрибута disabled самой формы
  formDisabledCallback(isDisabled) {
    this.disabled = isDisabled;
  }

  // ...
}

Вообще есть ещё и третий метод — formStateRestoreCallback. Он вызывается браузером с двумя аргументами: state и reason. Если reason = restore, то state будет равен последнему значению, которое мы передали в форму через internals.setFormValue(). Если reason = autocomplete, то state будет равен тому, что браузер пытается предзаполнить. Интересно, что reason = "restore" связан с back-forward cache, то есть значение запоминается при переходах с одной страницы на другую и обратно.

Также нужно сделать API для чтения value компонента, чтобы доставать его из событий:

combobox.addEventListener('input', event => event.target.value);
combobox.addEventListener('change', event => event.target.value);

Для этого реализуем пару геттеров — value и valueAsArray. Первый, привычный, возвращает знач��ние в виде строки, а второй — специфичное для нашего компонента значение.

Любой элемент формы в HTML — все типы inputselecttextarea или div с атрибутом contenteditable — в качестве value могут содержать только строки. Некоторые инпуты поддерживают «кастование»: например, у input с типом date можно также получить значение в виде числа (valueAsNumber) или в виде Date (valueAsDate).

Наш <combo-box> по семантике ближе к <select> с атрибутом multiple, поэтому к value мы добавляем valueAsArray — просто для удобства:

class HTMLComboboxElement extends HTMLElement {
  // ...
  
  get value() {
    return this.#value.join(',');
  }
  
  get valueAsArray() {
    return this.#value;
  }

  // ...
}

И раз уж компонент похож на <select>, нам ничего не стоит сделать его полностью совместимым с интерфейсом HTMLSelectElement. Это полезно хотя бы потому, что большая часть документации компонента будет хранится на MDN. Мы уже частично реализовали это выставив наружу API ElementInternals. Реализацию оставшихся методов можно посмотреть на гитхабе, в них нет ничего интересного или сложного.

Почему не is?
Может показаться, что
class HTMLComboboxElement extends HTMLSelectElement { ... } было бы проще, но, во первых, Safari не планирует поддерживать этот API, во вторых – это менее удобно в записи – <select is="combo-box"> , а в третьих, многие методы все равно пришлось бы переопределить из-за ограничений на стилизацию <select> и <option>.
Подробнее эти ограничения рассмотрим ниже.

Напоследок обеспечим accessibility. Это всё уже встроено в ElementInternals, нам остаётся только заполнить значения соответствующих ARIA-атрибутов. Мы заполним только два для примера, а с полным списком вы можете ознакомиться в документации. Разумеется, эти атрибуты можно, и нужно изменять в рантайме в ответ на какие-то события.

class HTMLComboboxElement extends HTMLElement {
  // ...
  
  constructor() {
    super();
    this.internals = this.attachInternals();

    this.internals.role = "combobox";
    this.internals.ariaHasPopup = "listbox";
  }

  // ...
}

Вот теперь наш компонент — полноценный элемент формы, неотличимый от <input> или <select>.

События

Есть такое заблуждение, что для работы с событиями в веб-компонентах нужен именно CustomEvent. Разумеется это не так. Мы можем использовать встроенные браузерные события, когда они уместны, но часто даже этого не требуется. В нашем случае, такие события как focus или blur, компонент поддерживает из коробки благодаря параметру delegateFocus:

class HTMLComboboxElement extends HTMLElement {
  // ...
  
  constructor() {
    super();
    // ...
    this.shadowRoot = this.attachShadow({ delegateFocus: true });
    // ...
  }

  // ...
}

С этим параметром в значении true наш компонент попадает в tab-последовательность, и при нажатии на Tab (когда до него дойдёт очередь) или при программном вызове element.focus() он получит фокус. Это не совсем настоящий фокус, потому что работает он чуть иначе.

Во-первых, компонент будет соответствовать псевдоклассам :focus:focus-within и :active. Во-вторых, если у него есть зарегистрированные обработчики событий focus – они будут вызваны. Но сам фокус перейдёт к первому focusable-элементу внутри его Shadow DOM. В нашем случае это будет первый тег в списке выбранных тегов (если есть хотя бы один), или к текстовому полю ввода в выпадающем списке (если оно включено соответствующим атрибутом), а иначе – к первому элементу в списке опций для выбора. Ну а при потере фокуса сработает событие blur. Всё это происходит автоматически.

Если поле ввода в выпадающем списке видимо, то событие input этого поля ввода само всплывёт наружу, при этом target в нём будет сам наш элемент. Поэтому мы реализуем ещё одну пару get/set — query. Это полезное свойство, например, для search-on-type с походом на сервер за новыми данными.

Единственное событие, которое мы будем сами диспатчить (вручную), — это событие change при выборе или удалении тега, либо полной очистке:

class HTMLComboboxElement extends HTMLElement {
  // ...

  #onChange(value) {
    // ...
    
    this.dispatchEvent(new Event('change'));
    
    // ...
  }

  // ...
}

И именно в этом месте у нас небольшая проблема. Дело в том, что React до 19 версии, для веб-компонентов – полностью игнорирует установку обработчика события change, а также любых кастомных. Просто так. Игнорирует и всё. События focusblurinput и пр. – сработают, а change нет. Для него придётся писать ref.addEventListener(), или обновиться до 19 версии, или не пользоваться им вообще. Простите, просто у меня к Реакту очень сильные чувства!

То есть, за исключением особенностей React ниже 19-ой версии, никаких особенностей в работе с событиями нет, и использование веб-компонентов не тождественно использованию CustomEvent, так как мы можем диспатчить любые подходящие стандартные события.

Разметка

Это промежуточный этап перед наведением красоты, то есть стилизации. В этом блоке обсудим такие концепции как теневой DOM, шаблоны, слоты, и реализуем еще два очень простых элемента – <box-option> и <box-tag>.

Зачем мы используем <box-option> вместо <option>

Выше мы определились с тем, что <combo-box>, семантически наиболее близок к <select> и полностью реализовали его API в дополнении к функционалу самого <combo-box>. Было бы разумно сделать это и в отношении разметки, то есть разрешёнными «детьми» для нашего компонента были бы коллекции <option> или <optgroup>, но с <option> были бы проблемы. У нас кастомизируемый элемент, а браузеры слишком строго относятся к элементу <option>, и стилизовать его нельзя, даже если мы используем его отдельно от <select>. В целом это разумно, потому что защищает от желающих добавить в <option> какой-нибудь <button>.

А еще, для соблюдения семантики ��ам нужен атрибут selected у элемента <option>, но не все фреймворки об этом заботятся. Preact, например, просто его игнорирует, будто его не существует. Мой любимый React тоже его игнорирует, но уже не тихо, а очень даже громко:

Warning: 
Use the `defaultValue` or `value` on <select> instead of setting `selected` on <option>. 
Error Component Stack
    at option (<anonymous>)
    at select (<anonymous>)
    at div (<anonymous>)

Нельзя использовать атрибут selected. Здорово, правда? Поэтому мы сделаем компонент <box-option>, который полностью реализует интерфейс HTMLOptionElement, но позволяет себя стилизовать. Реализация <box-option> очень проста, и не стоит отдельного внимания, мы просто сделали то же, что описано в блоке про атрибуты, и даже не использовали shadowDom.

Теперь мы можем стилизовать и сами опции и группы опции, а разметка получилась привычной благодаря схожести с <select>:

<combo-box>
  <optgroup label="pets">
    <box-option value="cat">Cat</box-option>
    <!-- ...  -->
  </optgroup>

  <!-- или -->
  <box-option value="dog">Dog</box-option>
</combo-box>

Shadow DOM

Для начала поговорим про Shadow DOM, и в первую очередь про то, что для создания веб-компонента он вовсе необязателен. Создавая элемент без Shadow DOM, мы, по сути, получаем пустой <div>, но с методами жизненного цикла. В терминах React, это как useEffect() без зависимостей (connectedCallback()) и cleanup-функция в нём (disconnectedCallback()). Именно на этом основан жизненный цикл компонентов в Angular.

Shadow DOM нужен тогда, когда мы хотим реализовать, и главное – переиспользовать какой-то функционал. Тег <video>, <input> с типом time или date – это все примеры встроенных компонентов с Shadow DOM. Присваиваем мы его элементу методом attachShadow() с несколькими аргументами. Основные это delegateFocus, его мы обсудили в блоке про события, и mode. Mode отвечает за доступ из JavaScript к разметке теневого дома, если он open, мы можем получить доступ ко всем внутренностям. Остальные параметры используются редко, они хорошо задокументированы, вы можете ознакомиться с ними в документации.

Куда большее влияние на решение о том, использовать Shadow DOM или нет, оказывает CSS. Можно сказать, что никакие стили не проникают в Shadow DOM, за небольшим исключением: direction, visibility, color, line-height, text-align и всех font-*. Но даже в этих случаях – это почти правда, потому, что перезаписанные стили user-agent (кроме font-family, но в случае с Safari и оно тоже) – не проникают. Например, если у вас есть правило input { color: red }, то к <input> внутри Shadow DOM оно не применится. Еще проникают :root CSS переменные, и переменные объявленные для самого веб-компонента, включая те, что объявлены для «родителя» компонента. И это все. О том как, при необходимости, пробросить стили в Shadow DOM поговорим в блоке про стилизацию, а пока обсудим <template> и <slot>.

Шаблоны и слоты

Это очень простой концепт. <template> это такой элемент, который не рендерится браузером, а только хранит разметку для последующего использования. Шаблон может содержать любые элементы, которые при использовании шаблона будут отрендерены как есть, но также – слоты. <slot> это placeholder внутри Shadow DOM, куда проецируются элементы из Light DOM. Это как React компонент который содержит какую-то разметку и вставляет в нее полученные props, только в случае с шаблоном, prop – это живой DOM-элемент, который остается в Light DOM (со всем своим поведением, включая стили) но отображаются внутри Shadow DOM. Работает это так:

Реализуем компонент использующий шаблон:

class HTMLBoxTagElement extends HTMLElement {
  constructor() {
    super();

    const content = document.getElementById("tag-template").content;
    
    this.shadowRoot.appendChild(document.importNode(content, true));
  }
}

window.customElements.define('box-tag', HTMLBoxTagElement);

Сам шаблон:

<template shadowrootmode="open">
  <!-- Какая-то статичная часть -->
  <div>Hello</div>

  <!-- Место под «prop» -->
  <slot name="user" />
</template>

Пользуемся:

<box-tag>
  <div slot="user">Habr</div>
</box-tag>

Результатом будет Hello Habr. Тут важно отметить, что если мы передадим в <box-tag> какой-то элемент без атрибута slot, или даже просто текст – он будет проигнорирован. Чтобы этого не произошло, в шаблоне нужно определить пустой слот (без атрибута name), тогда такой неименованный контент попадет в него.

Важно отметить, что наличие JavaScript вовсе необязательно для работы таких компонентов, их можно реализовать с помощью Declarative Shadow Dom:

<box-tag>
  <template shadowrootmode="open">
    <!-- Какая-то статичная часть -->
    <div>Hello</div>

    <!-- Место под «prop» -->
    <slot name="user" />
  </template>
</box-tag>

С одной стороны, это могло бы быть полезно для SSR, но это не совсем так.
Проблема в том, что не существует механизма переиспользования шаблонов. Если на странице два экземпляра <box-tag>, каждый получит свою полную копию <template>, и никакие SSR-фреймворки это не оптимизируют. В финальном HTML шаблон будет дублироваться для каждого компонента, и это достаточно странно, ведь у нас есть неплохо обкатанный способ переиспользования фрагментов XML – <use> в SVG.

На первый взгляд, <template> и <slot> кажутся идеальным решением для кастомизации, но на практике это не так. Обратите еще раз внимание на реализацию: в одном месте у нас шаблон, в другом реализация компонента (который должен знать id шаблона), а в третьем месте разметка. В результате получается, что:

  • как пользователь этого компонента, мне неизвестно какие слоты я должен ему передать;

  • как владелец элемента HTMLBoxTagElement, мне неизвестно какие слоты будут в шаблоне;

  • как автор шаблона я не контролирую его стили.

Последний пункт может сбить столку, поэтому поясню. В шаблоне мы сразу можем определить стили, а внутри этих стилей (и только внутри shadowRoot), нам доступен селектор ::slotted(), который в качестве аргумента принимает... не идентификатор слота! То есть так стилизовать слот не получится:

<template id="tag-template">
  <style>
    :slotted(label) {
      color: red; // не сработает!
    } 
    
  </style>
  
  <slot name="label" />
</template>

::slotted() лишь означает – любой элемент с атрибутом slot, а какой именно, мы должны указать передав ему селектор:

<template id="tag-template">
  <style>
    :slotted(span) {
      color: red; // работает
    } 

    :slotted(tag-label) {
      color: red; // работает
    }
    
  </style>
  
  <slot name="label" />
</template>


<box-tag>
  <span slot="label" class="tag-label">Habr</span>
</box-tag>

Но ведь как автор шаблона – я понятия не имею какие классы или идентификаторы задаст пользователь... По этой причине, для реализации кастомных тегов нашего <combo-box> мы позаимствуем только часть идеи шаблонов и слотов.

Реализация кастомных тегов

Для реализации <box-tag> мы совместим идею слотов с идеей Customizable select elements. То есть, если пользователь хочет кастомизировать тег, мы ожидаем от него некий шаблон, который будем использовать для вставки значений из выбранной опции, а вот как пользователю передать нам шаблон, подскажет customizable select:

// Customizable select
<select>
  <button>
    <selectedcontent></selectedcontent>
  </button>
  
  <option></option>
  ...
</select>

// Customizable comboox
<combo-box>
  <box-tag>
    <!--  ...  -->
  </box-tag>
  
  <box-option></box-option>
  ...
</combo-box>

Мы воспользуемся тем, что такие атрибуты как class, slot и part являются глобальными, и могут быть применены к любому элементу. Тогда наш <box-tag> может выступать в качестве <template>:

<!-- используем part -->
<box-tag>
  <slot name="foo" part="label" />
  <button part="clear">✕</button>
</box-tag>

<!-- используем классы -->
<box-tag>
  <slot name="foo" class="label" />
  <button class="clear">✕</button>
</box-tag>

То есть <box-tag> может содержать как статичные части (например, кнопку очистки), так и динамические слоты, которые заполняются данными из выбранной <box-option>. Работает это так: при выборе опции из списка, обнаруживаем в ее разметке все теги с атрибутом slot, далее находим соответствующий слот в шаблоне тега, создаем клон элемента из опции и заменяем в клоне. Чтобы стили, написанные для тега, применялись к склонированному содержимому, мы переносим значения part и class из слота в шаблоне на клон элемента из опции, а это значит, что независимо от выбранной стратегии стилизации – стили применяться.

Теперь представим, что в выпадающем списке нам нужно показать список стран, а для каждой страны – название, иконку с флагом, код страны и название столицы. В самом теге, мы хотим показать только флаг и название страны. В этом случае разметка будет такой:

function CountrySelect() {
  return (
    <combo-box>
      <box-tag>
        <slot name="flag" class="tag-flag" />
        <slot name="country-name" class="tag-label" />
      </box-tag>

      {countries.map(country => (
        <box-option value={country.id}>
          <img 
            slot="flag" 
            src={countri.flagImage} 
            class="option-coutry-flag" 
          />
          <div part="country-info">
            <div 
              slot="country-name" 
              class="option-country-name"
            >
              {country.name}
            </div>
            <div part="capital-name">{country.capital}</div>
            <div part="country-code">{country.code}</div>
          </div>
        </box-option>
      ))}
    </combo-box>
  )
}

Это достаточно удобно, потому, что разработчик сразу видит что с чем будет сопоставлено: option.slot -> box-tag.slot, но главное – тег можно кастомизировать как угодно.

Теперь, когда у нас есть разметка, можем приступить к стилям.

CSS в Light DOM

Начнём с самого элемента <combo-box>. По сути, это <input>, поэтому стилизуем его также. Как упоминалось выше, он ещё поддерживает соответствующие псевдоклассы, например:

combo-box:disabled { /*    */ }

combo-box:required { /*    */ }

combo-box:invalid { /*    */ }

combo-box:focus-visible {  /*    */ }

Это покрывает 90% потребностей, так как чаще всего <combo-box> нужен для чего-то простого, типа списка тегов, категорий и т.п. Если нужно изменить внешний вид «тегов» или, например, layout выпадающего списка, это доступно через ::part:

combo-box::part(clear-button) { /*    */ }

combo-box::part(tag) { /*    */ }

combo-box::part(search-input) { /*    */ }

combo-box::part(box-option) { /*    */ }

/* ... и другие.. */

Сложности начинаются тогда, когда <box-option> содержит сложную разметку. Так как внешние стили не видны из shadowRoot, выбор стратегии их проброса зависит от задачи. Я покажу три способа, расположив в порядке убывания по предпочтительности, но все рабочие.

  1. part вместо class
    Если использовать в разметке part вместо class, то стили не нужно пробрасывать. То есть размечаем <box-option>:

    <box-option>
      <div part="list-item">
        <!--   ...   -->
      </div>
    </box-option>

    и стилизуем как и другие части комбобокса:

    combo-box::part(list-item) { /*    */ }

    Это позволяет точечно, под конкретную задачу, стилизовать элементы выпадающего списка. В одном месте это list-item, в другом — users-list-item.

  2. Inline стили
    Рабочий метод. Минус – дублирование стилей в каждом элементе.

  3. Полный проброс всех стилей в shadowDom
    Способ может показаться радикальным, но иногда он самый удобный, а для некоторых задач — вообще единственный. О нём нужно рассказать в деталях.

Проброс CSS из Light в Shadow Dom

Есть пара способов это сделать. Первый и самый надёжный — передать в компонент ссылки на нужные CSS-файлы. В нашем компоненте мы реализуем статический метод importCSS(urls), а переданные ссылки просто зарегистрируем как:

<style>
  @import "url1"
  @import "url2"
  <!--  ...  -->
</style>

Этот способ предпочтительнее, потому что файлы не скачиваются повторно и нет проблем с директивой @import. На этом отличия от других способов заканчиваются, и независимо от того, как мы пробросили стили в shadowRoot, создаётся копия соответствующего CSSStyleSheet.

Второй способ, которым лично я, признаюсь, пользуюсь, — это тот же проброс всех стилей, но автоматический. В своих приложениях я избегаю использования любых CSS-фреймворков или UI-китов, соответственно контролирую CSS, включая его размер, поэтому этот способ мне подходит. Он заключается в использовании adoptedStyleSheets. Один раз после загрузки страницы пробегаемся по document.styleSheets и копируем все CSSRule в один экземпляр CSSStyleSheet. Далее этот экземпляр подкладываем в shadowRoot.adoptedStyleSheets каждого созданного экземпляра нашего компонента. Это значит, что если мы отрисуем 1000 <combo-box> на странице, они все будут использовать один и тот же экземпляр CSSStyleSheet, содержащий все стили из Light DOM:

for (const outerSheet of document.styleSheets) {
  for (const rule of outerSheet.cssRules) {
    innerSheet.insertRule(rule.cssText, innerSheet.cssRules.length)
  }
}

Преимущества этого способа в том, что внутри компонента можно использовать те же селекторы, что и во всём приложении, и всё просто работает. Но есть и недостатки. Первый — это, конечно, память, так как создаём копию стилей. Второй — важное ограничение: если экземпляр CSSStyleSheet из document.styleSheets содержит директиву @import, его не получится скопировать, потому что constructed stylesheet (а наш экземпляр, созданный с помощью new CSSStyleSheet, таковым является) не позволяет вставлять CSSImportRule:

SyntaxError: Failed to execute 'insertRule' on 'CSSStyleSheet': 
Can't insert @import rules into a constructed stylesheet.

Кроме того, способ явно не подходит, если стили на страницу добавляются динамически, потому что придётся как-то отслеживать появление новых стилей и подкидывать их по мере появления, что не очень удобно.

SSR

Давайте признаем: веб-компоненты создавались для CSR. Они изначально проектировались для решения проблемы переиспользования кода, которую ещё до React и Vue пытались решать jQuery UI, а позже Backbone и Knockout. Без JavaScript в веб-компонентах нет смысла. Declarative Shadow DOM появился недавно и не решает всех проблем. HTML Modules даже не дошли до stage 0 и фактически заброшены. Поэтому всё, что мы можем, это постараться избежать FOUC до загрузки скриптов.

C другой стороны, если вы разрабатываете статичный блог без JavaScript, то вам не нужны веб-компоненты, а если сложное приложение, то без JavaScript не обойтись.

За внешний вид элемента до момента его регистрации через customElements.define() отвечает связка псевдоклассов :not и :defined. Придадим компоненту форму, напоминающую <input>:

combo-box:not(:defined) {
  min-inline-size: 20ch;
  min-block-size: 1lh;
  border: 1px solid ButtonBorder;
  border-radius: 2px;
}

Спрячем <box-tag>, если он включен в разметку:

combo-box:not(:defined) box-tag {
  display: none;
}

Для большей красоты сэмулируем рабочий <combo-box> придав опциям вид тегов:

combo-box:not(:defined) {
  display: flex;
  flex-direction: row;
  gap: 5px;
  overflow-x: hidden;
}

combo-box:not(:defined) box-option {
  background-color: Highlight;
  border-radius: inherit;
  padding: 2px 4px;
}

В результате, до загрузки JavaScript наш элемент будет иметь вполне сносный вид:

Это всё, что мы действительно можем сделать.

Тестирование

Здесь применимы те же аргументы, что и к SSR. Реально тестировать такой компонент можно только в браузере, например с помощью Playwright. Другие способы нежизнеспособны.

Результат

Мы реализовали один сложный и два вспомогательных веб-компонента, написав около тысячи строк кода. Это включают разметку шаблона, стили и всю логику разных режимов работы, в том числе, по сути, полифил для Customizable Select, которого вообще нет в Safari и Firefox.

Для использования не требуется импорт в каждый файл, достаточно импортировать модуль в корне приложения:

import 'combobox';
import App from './App';

render(App, document.getElementById("app")!);

И главное – он не привязан к какому-либо фреймворку, а работает одинаково во всех. Примеры:

Может показаться, что кода — много, но смотря с чем сравнивать. С учётом того, что классы плохо минифицируются, мы получили 5 кб GZIP. Для сравнения я взял компонент Autocomplete из Material UI (потому что в отличие от Antd или отечественного Gravity UI у него нормальный tree-shaking). Он занимает 43 кб GZIP, и это не считая размер остальных модулей, без которых он в принципе работать не может. Огромная разница. Смею предположить, что аналоги сопоставимы по размеру.

Выводы

Веб-компоненты классный, но, кажется, недопонятый инструмент. По какой-то причине их восприняли как некую замену React и ему подобных. React их саботировал долгое время и начал полноценно поддерживать только с 19 версии. Большие ребята, например Google с его Material Components или Microsoft со своим Fluent UI, почему-то решили, что веб-компоненты нужны для создания кнопок, но это полностью лишено смысла – в HTML уже есть <button>. Поле для выбора диапазона дат, dual-range, combo-box, вот для решения таких задач они предна��начены, и такое решение будет лаконичнее и надёжнее нагромождения кучи <div>, прибитых к определённому фреймворку.

Репозиторий на Гитхабе

NPM