Construyendo Web Components — Guía Práctica | manufosela.dev

Guía de Web Components

Construyendo Web Components —
Guía Práctica

Principios, patrones y buenas prácticas contrastadas en producción, extraídas de más de 50 componentes reales. Sin dependencia de framework, centrado en el estándar, y con opiniones propias sin complejos.

Por manufosela~25 min de lecturaActualizado abril 2026

Consejo: en escritorio, verás una tabla de contenidos fija a la izquierda.

01 Cuándo (y cuándo no) construir un Web Component

El error más grande que cometen los desarrolladores no es construir malos componentes — es construir componentes innecesarios. Antes de llegar a customElements.define, hazte cuatro preguntas.

Los cuatro criterios

Un fragmento de interfaz merece ser un custom element si cumple al menos uno de estos:

  1. Es genuinamente reutilizable. Aparecerá en más de un lugar, en más de un proyecto, o tiene un propósito suficientemente genérico como para que otros equipos puedan usarlo sin modificarlo.
  2. Ofrece una presentación que no existe en HTML. Un componente <my-rating-stars> tiene sentido. Un <my-div-with-padding> no.
  3. Tiene un comportamiento encapsulado con significado propio. Hace algo — gestiona interacciones, mantiene estado, orquesta elementos hijos. El layout puro no es comportamiento.
  4. Necesita abstraer su diseño de su contexto. Puede insertarse en cualquier página o cualquier framework sin saber nada sobre la aplicación que lo aloja.

La trampa del framework

La mayor parte de la formación en frontend nos enseña cómo usar un framework, no cuándo usarlo. El modelo mental resultante es: toda interfaz es un componente, y todo componente necesita un framework. Los web components son la respuesta del estándar a esto: se ejecutan de forma nativa en cualquier navegador, no arrastran ningún runtime, e interoperan con React, Vue, Angular, Svelte — o sin ningún framework.

Muchos sitios no necesitan un framework. Una página de producto, un sitio de documentación, un portfolio — pueden construirse con HTML, CSS y un puñado de custom elements. Empieza con la plataforma. Añade abstracciones solo cuando la plataforma se quede corta.

02 Un componente, una responsabilidad

El Principio de Responsabilidad Única se aplica a los web components con especial fuerza, porque los componentes se exponen como API pública. Un componente que hace demasiado es difícil de usar, difícil de probar e imposible de razonar.

Reconocer el exceso de responsabilidades

La señal más clara de que un componente ha crecido demasiado es la longitud del archivo. Si tu archivo supera las ~300 líneas, casi seguro que está haciendo más de una cosa. Otras señales:

  • Tiene más de cinco o seis atributos observados.
  • Su connectedCallback tiene 20+ líneas de lógica de inicialización.
  • Encuentras ramas de renderizado condicional para estados fundamentalmente distintos.
  • Su README tiene más de tres ejemplos de uso, todos significativamente diferentes.

Estrategias de división

Cuando un componente se vuelve demasiado complejo, divídelo por sus costuras naturales. Patrones habituales:

Split: container / presentational
<!-- Bad: one component handles data fetching AND rendering -->
<user-profile-card user-id="42"></user-profile-card>

<!-- Better: separate concerns -->
<user-data-provider user-id="42">
  <user-profile-card></user-profile-card>
</user-data-provider>

Mixins para comportamiento compartido

Si varios componentes comparten un comportamiento (arrastrar y soltar, carga diferida, un patrón ARIA concreto), extráelo a un mixin en lugar de una clase base o una importación de utilidad. Los mixins mantienen la jerarquía de clases plana y el comportamiento compartido desacoplado.

mixin-focusable.js
export const FocusableMixin = (Base) => class extends Base {
  connectedCallback() {
    super.connectedCallback?.();
    if (!this.hasAttribute('tabindex')) {
      this.setAttribute('tabindex', '0');
    }
    this.addEventListener('keydown', this.#handleKeydown);
  }

  disconnectedCallback() {
    super.disconnectedCallback?.();
    this.removeEventListener('keydown', this.#handleKeydown);
  }

  #handleKeydown = (e) => {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      this.click();
    }
  };
};

03 Cómo nombrar tus componentes

Los nombres de etiquetas de custom elements son globales y no se pueden redefinir. Elígelos con cuidado, porque un conflicto en el registro romperá silenciosamente uno de los dos elementos en colisión.

Usa un prefijo de espacio de nombres

Los nombres de etiqueta deben contener un guion (así es como la especificación distingue los custom elements de los futuros elementos HTML). Usa el nombre de tu organización o sistema de diseño como prefijo:

<!-- Bad: likely to collide -->
<calendar></calendar>
<modal></modal>

<!-- Good: namespaced -->
<manufosela-calendar></manufosela-calendar>
<my-ds-modal></my-ds-modal>

El prefijo sirve también como mecanismo de descubrimiento: los desarrolladores hacen grep de manufosela- para encontrar todos los componentes de una biblioteca concreta.

Sé descriptivo, no ingenioso

El nombre debe comunicar qué es el componente, no cómo está implementado. <mf-accordion> es bueno. <mf-collapsible-details-wrapper> es demasiado específico de implementación. <mf-thing> no dice nada.

PatrónEjemploVeredicto
Sin espacio de nombres<calendar>Riesgo de colisión — evitar
Prefijo de organización<mf-calendar>Bien
Prefijo de sistema de diseño<manufosela-calendar>Mejor — sin ambigüedad
Versión en el nombre<mf-calendar-v2>Solo si convive necesariamente con v1

04 Flujo de datos: atributos hacia dentro, eventos hacia fuera

Esta es la regla de oro del diseño de API en web components. Los datos fluyen hacia abajo mediante atributos y propiedades. Los eventos fluyen hacia arriba. Rompe esta regla y estarás peleando con tu componente para siempre.

Atributos vs. propiedades — conoce la diferencia

Un atributo forma parte del marcado HTML — vive en el árbol del DOM, se serializa como cadena de texto y puede establecerse antes de que el elemento esté registrado. Una propiedad pertenece al objeto JavaScript — se asigna programáticamente y puede contener cualquier valor.

// Attribute — set in HTML or via setAttribute
<mf-badge count="5" variant="info"></mf-badge>
element.setAttribute('count', '5');

// Property — set via JS, ideal for complex data
element.items = [{ id: 1, label: 'Apple' }, { id: 2, label: 'Banana' }];
element.config = { debounce: 300, threshold: 0.5 };

Usa estas heurísticas:

  • Atributos: booleanos, cadenas, números, enumeraciones, IDs. Todo lo que escribirías en HTML.
  • Propiedades: arrays, objetos, funciones, datos binarios. Todo lo que no puede hacer un viaje de ida y vuelta por una cadena sin perder información.

Reflejar atributos en propiedades

Expón tus atributos observados como propiedades JS mediante getters/setters para que los consumidores puedan usar cualquiera de las dos APIs:

mf-badge.js
class MfBadge extends HTMLElement {
  static observedAttributes = ['count', 'variant'];

  get count() {
    return Number(this.getAttribute('count') ?? 0);
  }

  set count(val) {
    this.setAttribute('count', String(val));
  }

  get variant() {
    return this.getAttribute('variant') ?? 'default';
  }

  set variant(val) {
    this.setAttribute('variant', val);
  }

  attributeChangedCallback(name, oldVal, newVal) {
    if (oldVal === newVal) return;
    this.#render();
  }

  #render() {
    // ...
  }
}

customElements.define('mf-badge', MfBadge);

Custom events para la comunicación saliente

Cuando un componente necesita informar al exterior de que algo ha ocurrido, lanza un CustomEvent. Usa una convención de nombres consistente: minúsculas, separado por guiones, prefijado con el nombre del componente si hay riesgo de colisión.

dispatching events
// In your component — something happened
this.dispatchEvent(new CustomEvent('mf-select', {
  bubbles: true,    // propagate up the DOM tree
  composed: true,   // cross shadow DOM boundaries
  detail: {
    value: this.#selectedValue,
    label: this.#selectedLabel,
  },
}));

// Consumer side
document.querySelector('mf-combobox')
  .addEventListener('mf-select', (e) => {
    console.log('Selected:', e.detail.value);
  });

No lances eventos en respuesta a asignaciones de propiedades

Si el padre hace element.value = 'foo', el padre ya sabe cuál es el nuevo valor. Lanzar un evento change en respuesta crea un bucle de retroalimentación y sorprende a los consumidores. Reserva los eventos para las interacciones del usuario y las finalizaciones asíncronas internas — cosas que el padre no pudo haber iniciado.

05 Comunicación entre componentes

Los componentes deben ser autónomos. No deberían saber nada de sus hermanos. No deberían recorrer el árbol del DOM buscando padres. La comunicación ocurre a través de eventos.

Eventos antes que jerarquía

Cuando el componente A necesita decirle algo al componente B, la tentación es escribir parent.querySelector('mf-b').doSomething(). Resístela. En el momento en que introduces una referencia directa, has acoplado dos componentes que deberían ser independientes.

En su lugar, lanza un evento hacia arriba y deja que la aplicación (o un contenedor coordinador) retransmita los datos hacia abajo como atributo:

// mf-search dispatches upward
searchEl.addEventListener('mf-search', (e) => {
  // Application layer — the only one that knows about both components
  resultsEl.setAttribute('query', e.detail.query);
  resultsEl.setAttribute('loading', '');
  fetchResults(e.detail.query).then((data) => {
    resultsEl.removeAttribute('loading');
    resultsEl.items = data;
  });
});

Pensamiento fenotípico

El Atomic Design organiza los componentes por tamaño: átomos, moléculas, organismos. Es un modelo mental útil, pero fomenta pensar en los componentes como un árbol genealógico (padres que contienen hijos que contienen nietos). Esto lleva al acoplamiento rígido.

Un marco más útil es el fenotípico: clasifica los componentes por sus rasgos visibles y cómo interactúan con su entorno. Un <mf-tooltip> se define por cómo aparece (cerca de su disparador, flotando, transitorio) y cómo responde a su entorno (foco, hover, scroll). No le importa si su disparador es un átomo botón o una molécula de formulario compleja.

Los componentes deben ser simbióticos, no familiares. Coexisten y cooperan a través de convenciones compartidas (eventos, atributos, slots), no a través del conocimiento directo mutuo.

06 Usa el light DOM

El shadow DOM es poderoso, pero es opaco — para los motores de búsqueda, para los lectores de pantalla (en parte), y para la capa de aplicación. Úsalo para la implementación; usa slots y light DOM para el contenido.

El shadow DOM es invisible para los crawlers

El contenido dentro de un shadow root no está indexado por los motores de búsqueda (a fecha de 2026, Google puede indexarlo parcialmente, pero la situación es inconsistente). El contenido que necesita ser descubierto — encabezados, texto del cuerpo, nombres de productos, etiquetas de navegación — pertenece al light DOM.

Diseña tus componentes para que acepten HTML semántico como contenido mediante slots, y deja que el componente aplique el tratamiento visual:

mf-card.html (consumer)
<!-- Light DOM content — indexable, accessible, semantic -->
<mf-card>
  <h2 slot="title">Product Name</h2>
  <p slot="description">A short description of this product.</p>
  <img slot="image" src="/product.jpg" alt="Product photo" />
  <a slot="action" href="/buy">Buy now</a>
</mf-card>
mf-card.js (component)
class MfCard extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        :host { display: block; border-radius: 0.5rem; overflow: hidden; }
        .card { display: grid; gap: 1rem; padding: 1.5rem; }
        /* style slots — the light DOM content inherits parent styles too */
        ::slotted([slot="title"]) { font-size: 1.25rem; margin: 0; }
        ::slotted([slot="action"]) { color: var(--primary); }
      </style>
      <div class="card">
        <slot name="image"></slot>
        <slot name="title"></slot>
        <slot name="description"></slot>
        <slot name="action"></slot>
      </div>
    `;
  }
}

Nunca pases JSON como atributos

Este patrón aparece en todas partes y es incorrecto en varios niveles:

<!-- DO NOT DO THIS -->
<mf-list items='[{"id":1,"label":"Apple"},{"id":2,"label":"Banana"}]'>
</mf-list>

Los problemas: no es indexable, se rompe cuando el JSON contiene comillas, es inaccesible para las tecnologías asistivas, y filtra la estructura de datos al marcado.

El enfoque correcto es poner los datos en el light DOM como HTML semántico, y dejar que el componente los lea y los mejore en el momento del renderizado:

the right way
<mf-list>
  <li data-id="1">Apple</li>
  <li data-id="2">Banana</li>
</mf-list>

<!-- component reads it at connectedCallback -->
mf-list.js
connectedCallback() {
  const items = [...this.querySelectorAll('li')].map((li) => ({
    id: li.dataset.id,
    label: li.textContent.trim(),
  }));
  this.#render(items);
}

El contenido ya estaba en el light DOM. Los motores de búsqueda lo indexan. Los lectores de pantalla pueden acceder a él incluso antes de que cargue JavaScript. El componente lo mejora — no lo reemplaza.

07 Gestión de errores: falla ruidosamente

Los fallos silenciosos son los peores. Si falta un atributo requerido, si una llamada a la API devuelve basura, si la configuración es inválida — dilo con claridad. Tu yo del futuro te lo agradecerá.

Lanza excepciones ante requisitos críticos

Si un componente no puede funcionar sin un atributo o propiedad concretos, lanza un error en connectedCallback. No renderices un estado vacío roto. No registres una advertencia que quedará enterrada en la consola. Lanza.

connectedCallback() {
  const src = this.getAttribute('src');

  if (!src) {
    throw new Error(
      `<mf-video-player> requires a "src" attribute. ` +
      `Example: <mf-video-player src="/video.mp4"></mf-video-player>`
    );
  }

  this.#init(src);
}

Valida, no asumas

Cuando el valor de un atributo viene del exterior (es decir, de un consumidor), valídalo contra el conjunto de valores esperados antes de usarlo:

static #VALID_VARIANTS = new Set(['default', 'info', 'success', 'warning', 'error']);

attributeChangedCallback(name, _, newVal) {
  if (name === 'variant') {
    if (!MfBadge.#VALID_VARIANTS.has(newVal)) {
      console.warn(
        `<mf-badge>: Unknown variant "${newVal}". ` +
        `Valid values: ${[...MfBadge.#VALID_VARIANTS].join(', ')}`
      );
      return; // don't render an invalid state
    }
  }
  this.#render();
}

Degradación elegante vs. fallos duros

Distingue entre requisitos verdaderamente críticos (ausente = roto) y mejoras opcionales (ausente = funcionalidad reducida):

  • Crítico:src en un reproductor de medios, href en un componente de enlace → lanza excepción
  • Opcional:variant en un badge, icon en un botón → avisa + usa valor por defecto
  • Mejora:prefers-reduced-motion, modo oscuro → detecta + adapta silenciosamente

08 Accesibilidad primero

La accesibilidad no es un elemento de una lista de comprobación. Es una métrica de calidad. Un componente que no puede usarse con teclado o con un lector de pantalla es un componente roto.

Usa elementos nativos siempre que sea posible

La mayor ganancia de accesibilidad es no reimplementar lo que el navegador ya hace. Si necesitas un diálogo, envuelve <dialog>. Si necesitas un widget de revelación, construye sobre <details> y <summary>. El navegador te da gestión de teclado, gestión del foco y roles ARIA gratis.

→ Demo en vivo: app-modal (envuelve <dialog>) → Demo en vivo: behaviour-accordion (construido sobre <details>) app-modal — wrapping <dialog>
class AppModal extends HTMLElement {
  #dialog;

  connectedCallback() {
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        :host { display: contents; }
        dialog { border: none; border-radius: 0.75rem; padding: 0; }
        dialog::backdrop { background: rgba(0,0,0,0.6); }
      </style>
      <dialog part="dialog">
        <slot></slot>
      </dialog>
    `;
    this.#dialog = shadow.querySelector('dialog');
  }

  show() { this.#dialog?.showModal(); }
  close() { this.#dialog?.close(); }
}

ARIA cuando el nativo se queda corto

Cuando no hay ningún elemento nativo para el patrón que estás construyendo, añade ARIA explícitamente. Aplícalo al elemento host mediante :host o establécelo en connectedCallback:

connectedCallback() {
  // Set ARIA role on the host element
  if (!this.hasAttribute('role')) {
    this.setAttribute('role', 'tablist');
  }

  // Manage focus
  this.setAttribute('tabindex', '0');
  this.addEventListener('keydown', this.#handleKeyNav);
}

CSS custom properties para la tematización

Usa var() con fallbacks razonables para que tu componente participe en el sistema de diseño de la página sin requerir configuración:

:host {
  display: block;
  /* The host page can override these; fallbacks ensure the component works standalone */
  --_bg: var(--mf-surface, #1c1b1b);
  --_text: var(--mf-on-surface, #f4f5f2);
  --_accent: var(--mf-primary, #00e0b3);
  --_radius: var(--mf-radius, 0.5rem);

  background: var(--_bg);
  color: var(--_text);
  border-radius: var(--_radius);
}

Responsivo y sensible al movimiento

Los componentes deben funcionar en cualquier tamaño de ventana. Si tienes animaciones, respeta prefers-reduced-motion:

@media (prefers-reduced-motion: reduce) {
  :host, :host * {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

@media (prefers-color-scheme: dark) {
  :host {
    --_bg: var(--mf-surface, #131313);
  }
}

Imágenes: SVG antes que Base64

El SVG en línea escala nítidamente a cualquier resolución, no requiere petición HTTP adicional, admite animación CSS y mantiene el tamaño del archivo pequeño. Las imágenes codificadas en Base64 no tienen ninguna de esas cualidades. Usa SVG para iconos y gráficos simples. Usa <img> con alt descriptivo para fotografías.

09 Rendimiento

Algo que el ecosistema de frameworks no enfatiza suficiente: no necesitas una librería para construir web components. El navegador ya incluye todo lo que necesitas.

Sin framework necesario

Los web components son parte de la especificación HTML. HTMLElement, customElements, attachShadow, MutationObserver, IntersectionObserver — todas son APIs estándar del navegador, sin dependencias, sin coste de runtime.

// This ships as ~2KB minified + gzipped
// No React, no Vue, no Angular, no Lit (though Lit is great too)
class MfTooltip extends HTMLElement {
  static observedAttributes = ['text', 'placement'];

  connectedCallback() {
    this.attachShadow({ mode: 'open' }).innerHTML = `
      <style>/* ... */</style>
      <slot></slot>
      <span class="tooltip" part="tooltip"></span>
    `;
    this.#setup();
  }
  // ...
}

El primer pintado importa

Define tus componentes de forma que eviten el cambio de diseño (layout shift). Establece display en :host a un valor no en línea para que el elemento ocupe espacio antes de que se ejecute su JavaScript. Usa :not(:defined) para ocultar componentes no configurados de forma elegante:

/* In your component styles */
:host { display: block; }

/* In the host page CSS — hide undefined components to prevent FOUC */
mf-card:not(:defined),
mf-modal:not(:defined) {
  visibility: hidden;
}

Carga diferida donde corresponda

Los componentes que están por debajo del pliegue o que se muestran condicionalmente no necesitan estar en la ruta crítica. Usa import() dinámico junto con IntersectionObserver:

// Only load the component definition when it enters the viewport
const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (entry.isIntersecting) {
      import('./mf-heavy-chart.js');
      observer.unobserve(entry.target);
    }
  }
});

document.querySelectorAll('mf-heavy-chart')
  .forEach((el) => observer.observe(el));

Minimiza las dependencias externas

Cada dependencia que añades es una dependencia que tus consumidores tienen que descargar. Pregúntate por cada una: ¿realmente necesito una librería, o hay una API del navegador que hace lo mismo? Hoy en día, la respuesta es "API del navegador" más a menudo que nunca.

TareaLibreríaAlternativa nativa
AnimacionesGSAPWeb Animations API / transiciones CSS
Detección de intersecciónjQuery waypointsIntersectionObserver
Detección de redimensionadoresize-sensorResizeObserver
Mutación del DOMMutationObserver
Clonado profundolodash.cloneDeepstructuredClone()
IDs únicosuuidcrypto.randomUUID()

10 Documentación

Un componente sin documentar es un componente roto. Si alguien no puede entender cómo usarlo en cinco minutos, no lo usará — lo reescribirá.

La plantilla de README

Todo paquete de componente debería tener un README con esta estructura:

Secciones recomendadas del README

  1. Título — nombre del componente, descripción en una línea, badge de npm, enlace a demo
  2. Instalación — comando npm, snippet CDN, sentencia de importación
  3. Uso — ejemplo mínimo funcional en HTML y JS
  4. Atributos — tabla: nombre, tipo, valor por defecto, descripción
  5. Propiedades — tabla: nombre, tipo, descripción
  6. Eventos — tabla: nombre, forma del detail, cuándo se lanza
  7. CSS Custom Properties — tabla: nombre, valor por defecto, propósito
  8. Slots — tabla: nombre, descripción
  9. Notas — compatibilidad con navegadores, problemas conocidos, notas de migración

custom-elements.json (el manifiesto)

El Custom Elements Manifest es un archivo JSON legible por máquinas que describe la API de tu componente. Herramientas como Storybook, extensiones de VS Code y generadores de documentación lo consumen automáticamente.

# Generate from JSDoc comments using the analyser
npx @custom-elements-manifest/analyzer analyze --globs "src/**/*.js"

Anota tu componente con JSDoc para que el analizador pueda extraer el manifiesto:

documented component
/**
 * A badge component for displaying short status labels.
 *
 * @element mf-badge
 *
 * @attr {string} [variant=default] - Visual variant: default | info | success | warning | error
 * @attr {number} [count] - Numeric count to display
 *
 * @cssprop [--mf-badge-bg] - Badge background color
 * @cssprop [--mf-badge-radius=9999px] - Border radius
 *
 * @fires mf-dismiss - Fired when the dismiss button is clicked
 */
class MfBadge extends HTMLElement { /* ... */ }

11 Mantenibilidad

El mejor componente es el que sigue teniendo sentido seis meses después, cuando otro desarrollador abre el archivo a las 9 de la noche para corregir un bug en producción.

Versionado semántico

Sigue semver de forma estricta. Añadir un nuevo atributo opcional es un minor. Renombrar uno existente es un major (es un cambio que rompe compatibilidad). Corregir un bug es un patch. Mantén un CHANGELOG.md.

Privado por defecto

Usa campos privados de clase de JavaScript (prefijo #) para métodos internos y estado. Todo lo que no forme parte de la API pública debería ser privado. Esto mantiene la superficie documentada mínima y evita que los consumidores dependan accidentalmente de detalles de implementación.

class MfAccordion extends HTMLElement {
  // Private state — not accessible from outside
  #isOpen = false;
  #panel = null;

  // Private methods
  #toggle() {
    this.#isOpen = !this.#isOpen;
    this.#panel?.setAttribute('aria-hidden', String(!this.#isOpen));
  }

  // Public API — documented and stable
  open() { this.#isOpen = true; this.#render(); }
  close() { this.#isOpen = false; this.#render(); }
  toggle() { this.#toggle(); }

  get open() { return this.#isOpen; }
}

Tests: cobertura del 80% o más

Los web components son testables con las APIs estándar del DOM. Usa Vitest con happy-dom o @open-wc/testing para un entorno de pruebas en un navegador real:

import { fixture, html, expect } from '@open-wc/testing';
import '../src/mf-badge.js';

describe('mf-badge', () => {
  it('reflects the count attribute', async () => {
    const el = await fixture(html`<mf-badge count="5"></mf-badge>`);
    expect(el.count).to.equal(5);
  });

  it('fires mf-dismiss when the dismiss button is clicked', async () => {
    const el = await fixture(html`<mf-badge dismissible>Label</mf-badge>`);
    let fired = false;
    el.addEventListener('mf-dismiss', () => { fired = true; });

    el.shadowRoot.querySelector('[part="dismiss"]').click();
    expect(fired).to.be.true;
  });
});

JavaScript moderno — siempre

Escribe contra el estándar ECMAScript actual. Campos privados de clase, optional chaining, nullish coalescing, structuredClone, crypto.randomUUID, top-level await — todo esto está disponible en los navegadores evergreen. El código orientado a IE11 o entornos heredados queda fuera del alcance de esta guía.

// Modern, clean, idiomatic
class MfSelect extends HTMLElement {
  #options = [];
  #selected = null;

  get value() { return this.#selected?.value ?? null; }

  #findOption = (val) =>
    this.#options.find(({ value }) => value === val) ?? null;

  select(val) {
    this.#selected = this.#findOption(val);
    this.dispatchEvent(new CustomEvent('mf-change', {
      bubbles: true,
      composed: true,
      detail: { value: this.value },
    }));
  }
}

Lista de comprobación rápida

  • Campos privados (#) para todo el estado interno y los métodos
  • JSDoc en cada atributo público, propiedad, evento, CSS custom property y slot
  • static observedAttributes solo lista los atributos que el componente usa realmente
  • disconnectedCallback elimina todos los event listeners añadidos en connectedCallback
  • Tests para el camino feliz, el camino de error y cada evento público
  • CHANGELOG.md actualizado en cada release
  • custom-elements.json generado y commiteado
  • Demo en vivo enlazada desde el README

Manuel Fosela — manufosela

Arquitecto frontend y apasionado de los estándares web, afincado en España. Lleva construyendo web components desde la era del polyfill v0, ha publicado más de 50 custom elements de código abierto, y sigue convencido de que el mejor framework de UI es un HTMLElement bien pensado. Escribe sobre componentes, sistemas de diseño y la filosofía del desarrollo web.