Un custom element (Web Component) est une balise HTML que vous définissez vous-même, <atlas-rating>, et que le navigateur reconnaît partout : dans une page statique, dans un CMS, dans une application React ou dans une autre application Angular. @angular/elements en est le pont : createCustomElement() transforme un composant Angular en une classe qui étend HTMLElement. Les inputs deviennent des attributs et des propriétés, les outputs deviennent des CustomEvents, et le composant se crée tout seul quand la balise entre dans le DOM.
Les exemples de cette page sont réels : nous définissons les éléments à l’exécution avec customElements.define, les créons avec document.createElement dans un conteneur qu’Angular ne gère pas, et les écoutons avec addEventListener, exactement comme le ferait une page qui ne sait rien d’Angular. Tout s’exécute dans cette application, qui est zoneless.
createCustomElementtransforme un composant en Web Component utilisable sur n’importe quelle page : React, Vue, un CMS ou du HTML simple.- Les inputs deviennent des propriétés et des attributs (en kebab-case), et les outputs des événements
CustomEvent. - Avec le Shadow DOM, les styles de l’élément et ceux de la page ne se mélangent plus.
- Chaque élément apporte le runtime Angular. Plusieurs éléments sur la même page devraient venir du même bundle.
- Cela vaut la peine pour des widgets intégrés à des pages que vous ne contrôlez pas, pas pour une application Angular classique.
Angular Elements est le bon outil quand un composant doit vivre en dehors d’une application Angular : widgets pour un CMS ou pour les sites de clients, design system utilisé par des équipes sur différents frameworks, micro frontends qui ne peuvent pas partager la même version d’Angular. Dans une application Angular ordinaire, c’est presque toujours le mauvais choix : vous perdez la vérification des types dans le template, la hiérarchie de DI et la projection de contenu dynamique.
Ce que fait createCustomElement
createCustomElement(Component, { injector }) n’affiche rien. Il renvoie une classe (NgElementConstructor) que vous enregistrez avec customElements.define(tag, class). La classe lit les métadonnées du composant (reflectComponentType) et prépare trois choses :
observedAttributes: les noms des inputs en kebab-case (maxStars→max-stars), pour que le navigateur la notifie à chaque changement d’attribut ;- un getter et un setter sur le prototype pour chaque input, pour que
el.maxStars = 7fonctionne comme une propriété DOM ordinaire ; - une stratégie (
NgElementStrategy) qui, auconnectedCallback, appellecreateComponentavec l’élément commehostElement, attache la vue àApplicationRefet exécute un premierdetectChanges(), de façon synchrone, avant le retour deappend().
L’injecteur que vous passez est celui où le composant obtient ses services (HttpClient, stores, tokens de configuration). Dans un bundle dédié aux widgets, vous l’obtenez depuis createApplication(), une application sans composant racine ; dans une application existante, depuis ApplicationRef.injector.
import {
Component,
ViewEncapsulation,
booleanAttribute,
computed,
input,
linkedSignal,
numberAttribute,
output,
} from '@angular/core';
export interface RatingChange {
value: number;
previous: number;
}
@Component({
selector: 'atlas-rating-cmp', // ≠ le nom de la balise (voir plus bas)
encapsulation: ViewEncapsulation.ShadowDom, // le CSS de la page hôte n’entre pas
template: `
@for (s of stars(); track s) {
<button type="button" [class.on]="s <= current()" [disabled]="readonly()"
(click)="pick(s)">★</button>
}
`,
})
export class AtlasRating {
// attributs : value, max-stars, readonly
readonly value = input(0, { transform: numberAttribute });
readonly maxStars = input(5, { transform: numberAttribute });
readonly readonly = input(false, { transform: booleanAttribute });
// devient un CustomEvent 'ratingChange', la valeur dans event.detail
readonly ratingChange = output<RatingChange>();
// état local, réinitialisé quand l’hôte envoie une autre valeur
protected readonly current = linkedSignal(() => this.value());
protected readonly stars = computed(() =>
Array.from({ length: this.maxStars() }, (_, i) => i + 1),
);
protected pick(value: number): void {
const previous = this.current();
this.current.set(value);
this.ratingChange.emit({ value, previous });
}
}Le guide officiel demande explicitement de ne pas utiliser le selector du composant comme nom de balise. Si atlas-rating est à la fois le selector et le custom element, et que le composant est aussi importé dans un template, Angular crée deux instances sur le même élément. D’où la convention atlas-rating-cmp (selector) contre atlas-rating (balise). La balise doit contenir un tiret, et define() sur un nom déjà enregistré lève NotSupportedError : c’est pourquoi on vérifie d’abord avec customElements.get().
Zone.js n’est pas nécessaire. Depuis la v19, les éléments utilisent le scheduler de Change Detection ordinaire (changelog 19.0.0 : « switch to ComponentRef.setInput & remove custom scheduler »), et depuis la v21 les applications sont zoneless par défaut. Un setAttribute atteint componentRef.setInput(), qui marque la vue et planifie un rendu ; un clic dans le template de l’élément modifie un signal, exactement comme dans un composant OnPush ordinaire.
Définir dans une application existante
Le cas typique : l’application reçoit du HTML d’un CMS et l’insère avec innerHTML. Les composants Angular de ce HTML ne démarreraient jamais, mais les custom elements démarrent tout seuls, car le navigateur reconnaît leur balise.
// Dans une application existante (p. ex. du HTML venant d’un CMS, inséré avec innerHTML)
import { ApplicationRef, inject, provideAppInitializer } from '@angular/core';
import { createCustomElement } from '@angular/elements';
import { AtlasRating } from './rating';
function defineAtlasElements(): void {
const injector = inject(ApplicationRef).injector; // racine : vit aussi longtemps que l’application
// define() sur un nom déjà enregistré lève NotSupportedError
if (!customElements.get('atlas-rating')) {
customElements.define('atlas-rating', createCustomElement(AtlasRating, { injector }));
}
}
export const appConfig = {
providers: [provideAppInitializer(defineAtlasElements)],
};Attributs, propriétés, événements
Un élément a deux « entrées » pour un même input. L’attribut (max-stars="7") est toujours une string : c’est pourquoi les inputs numériques ou booléens ont besoin de transform: numberAttribute / booleanAttribute ; sans cela, maxStars() + 1 devient '71'. La propriété (el.maxStars = 7) accepte n’importe quel type : objets et tableaux ne passent donc que par là. En lecture, el.maxStars renvoie la valeur courante de l’input ; depuis la v21, c’est aussi vrai pour input() sur les signals (auparavant, il fallait appeler el.maxStars()).
| Input / output | Attribut HTML | Propriété JS | À retenir |
|---|---|---|---|
maxStarstransform: numberAttribute | max-stars="7" | el.maxStars = 7 | l’attribut arrive sous la forme '7', le transform en fait un nombre |
readonlytransform: booleanAttribute | readonly | el.readonly = true | removeAttribute envoie null → false |
userIdalias: 'user' | user="42" | el.userId | l’attribut utilise l’alias, la propriété le nom dans la classe |
itemsinput<Item[]> | — | el.items = [...] | via un attribut, il arriverait sous la forme "[object Object]" |
ratingChangeoutput<T>() | — | addEventListener('ratingChange', …) | CustomEvent, la valeur dans event.detail |
export class Widget {
// l’attribut = le nom de l’input (ou son alias) en kebab-case
readonly maxStars = input(5, { transform: numberAttribute }); // max-stars="7"
readonly compact = input(false, { transform: booleanAttribute }); // compact
readonly userId = input('', { alias: 'user' }); // user="42" (!)
readonly items = input<Item[]>([]); // propriété uniquement
// un output devient un CustomEvent portant le nom de l’output (ou l’alias)
readonly ratingChange = output<RatingChange>(); // 'ratingChange'
readonly closed = output<void>({ alias: 'atlasClosed' }); // 'atlasClosed'
}
// Ce que createCustomElement génère pour la classe ci-dessus :
// static observedAttributes = ['max-stars', 'compact', 'user', 'items']
// getter/setter sur le prototype : maxStars, compact, userId, items
// (la propriété utilise le nom dans la classe, l’attribut utilise l’alias)L’événement est créé avec new CustomEvent(name, { detail }), sans bubbles et sans composed. Un listener sur document ou sur le conteneur parent ne reçoit rien ; on écoute directement sur l’élément. Dans l’exemple ci-dessous, le conteneur a aussi un listener pour ratingChange : il n’apparaîtra jamais dans le journal.
// cliquez sur un bouton ou modifiez un champel.value: —- 09:11:33 customElements.define('atlas-rating', …) : défini maintenant
Remarquez un détail : après un clic sur une étoile, el.value reste la valeur reçue de l’hôte. L’input est un canal à sens unique ; le composant garde le choix dans un linkedSignal et l’annonce via un événement. Si l’hôte veut être la source de vérité, il écoute l’événement et réécrit el.value : le pattern du « composant contrôlé ».
// The event is created as new CustomEvent(name, { detail })
// → bubbles: false, composed: false.
el.addEventListener('ratingChange', (e: Event) => {
const { value } = (e as CustomEvent<RatingChange>).detail;
});
document.addEventListener('ratingChange', handler); // ✗ ne reçoit rien
// Si l’hôte a vraiment besoin de délégation (un listener pour 50 widgets),
// émettez explicitement, depuis le composant, un événement qui remonte et traverse le shadow root :
export class AtlasRating {
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private announce(detail: RatingChange): void {
this.host.nativeElement.dispatchEvent(
new CustomEvent('atlas:rating-change', { detail, bubbles: true, composed: true }),
);
}
}Cycle de vie
Le composant naît au connectedCallback et meurt au disconnectedCallback, mais pas immédiatement : la stratégie planifie destroy() après 10 ms (DESTROY_DELAY = 10 dans le code source du package). Si l’élément revient dans le DOM avant, par exemple lors d’un déplacement remove() + append() dans la même tâche, comme au tri d’une liste, la destruction est annulée et l’état interne subsiste. Les inputs définis avant la connexion sont mémorisés et appliqués à la création. De plus, les propriétés définies sur un élément pas encore défini (le script du widget n’est pas chargé) sont reprises à l’upgrade : l’hôte peut donc définir el.items à tout moment.
document.createElement('atlas-rating')
constructor ; pas encore de composant (les inputs définis maintenant sont mémorisés)
container.append(el)
connectedCallback → createComponent(...) DE FAÇON SYNCHRONE, attachView(appRef), detectChanges()
el.setAttribute('value', '4')
attributeChangedCallback → componentRef.setInput('value', '4') → transform → CD
el.value = 4
setter sur le prototype → même chemin, sans conversion en string
el.remove()
disconnectedCallback → destroy PLANIFIÉ dans 10 ms
↳ réajouté en < 10 ms (déplacement) destroy annulé : même instance, même état
↳ après 10 ms componentRef.destroy() → DestroyRef.onDestroy
container.append(el) // même instance, après destroy
⚠︎ le composant n’est PAS recréé : HTML mort + NG0953 dans la console
document.createElement('atlas-rating')
✓ instance neuve et propreLe guide officiel avertit (section Limitations, issue #38778) que détacher puis rattacher un élément créé avec @angular/elements pose problème. En v22.2.2, le comportement est visible dans la démo : une fois le composant détruit, un nouvel append de la même instance ne le recrée pas. Le HTML resté dans le shadow root est mort (les clics ne font rien), el.value vaut undefined, et Angular signale NG0953 (abonnement à un OutputRef détruit). Une seconde tentative lève NotSupportedError depuis attachShadow, car un élément ne peut pas avoir deux shadow roots.
Cela arrive quand quelqu’un réutilise le nœud DOM : le ng-if d’AngularJS, un éditeur de CMS qui déplace des blocs, un cache d’éléments dans votre code. La solution : ne réutilisez pas l’instance, créez un autre élément avec document.createElement, ou, si vous voulez seulement le masquer, laissez-le dans le DOM avec l’attribut hidden.
Shadow DOM et styles
Avec Emulated (le défaut), Angular réécrit les selectors du composant avec des attributs uniques : vos styles ne fuient pas vers l’extérieur, mais le CSS de la page entre. Un widget placé sur un site tiers hérite de toute règle comme .content p ou button { text-transform: uppercase !important }, le bug le plus courant des widgets. Avec ViewEncapsulation.ShadowDom, le template est rendu dans un shadow root (mode open), et les selectors de la page ne voient plus l’intérieur. Seules les propriétés héritées (font, color) et les variables CSS traversent, exactement ce qu’il faut pour un thème contrôlé.
Une nuance peu connue : en mode ShadowDom, Angular copie aussi dans chaque shadow root les styles des autres composants de la même application. Pour les composants Emulated, c’est du poids mort (leurs selectors ne correspondent pas), mais un composant avec ViewEncapsulation.None finit par styler aussi l’intérieur du widget. ExperimentalIsolatedShadowDom (expérimental depuis la v21) place uniquement les styles propres du composant dans le shadow root. Pendant le SSR, les deux modes shadow sont rendus comme Emulated.
| Encapsulation | Le CSS de la page entre-t-il ? | Styles None de l’application | Variables CSS |
|---|---|---|---|
Emulated (défaut) | oui | oui | oui |
ShadowDom | non | oui (copiés dans le shadow root) | oui |
ExperimentalIsolatedShadowDom | non | non | oui |
| Élément | el.shadowRoot | <style> dans le shadow root |
|---|
Le Shadow DOM change plus que les styles : les événements qui quittent un shadow root sont retargetés (event.target devient l’hôte), document.querySelector ne trouve rien de l’intérieur, et les tests e2e doivent percer le shadow root. Pour le thème, exposez des variables CSS et, si vous avez besoin de plus de contrôle, des attributs part stylables avec ::part().
@Component({
selector: 'atlas-card-cmp',
encapsulation: ViewEncapsulation.ShadowDom,
template: `
<h3 part="title">{{ title() }}</h3>
<ng-content />
`,
styles: `
:host { display: block; }
/* Les variables CSS et les propriétés héritées (font, color) ENTRENT dans le shadow root */
h3 { color: var(--atlas-accent, #2b45d4); font: inherit; font-weight: 650; }
`,
})
export class AtlasCard {
readonly title = input('');
}Build, bundle, distribution
Pour utiliser les éléments en dehors d’une application Angular, il faut un build séparé : une application dont le main.ts appelle createApplication() et customElements.define(), sans composant racine. Le schematic Native Federation propose même l’option --webcomponent, qui génère exactement ce bootstrap (voir Micro frontends).
Le coût, c’est le runtime. Un seul élément simple, construit avec Angular 22.2.2 en production, zoneless, représente ≈ 110 kB minifiés, ≈ 33 kB transférés (estimation du CLI), soit pratiquement @angular/core + platform-browser + un peu de RxJS. Trois widgets issus de trois builds différents sur la même page, ce sont trois copies du runtime, trois injecteurs et trois applications qui ne partagent aucun service.
- Regroupez les widgets d’une équipe dans un seul bundle : un
createApplication(), plusieurs appels àdefine(). - Des noms stables pour l’intégration via
<script src>:--output-hashing=noneet des dossiers versionnés (/1.4.0/main.js), pour pouvoir utiliser un cache long sans hash dans le nom. - Lazy à l’intérieur :
@deferetimport()fonctionnent aussi dans un élément, la partie lourde du widget peut donc arriver quand on en a besoin.
# un projet séparé dans le workspace, uniquement pour les widgets
ng generate application atlas-widgets --ssr=false
# main.ts : createApplication + customElements.define (voir main.ts ci-dessus)
# noms de fichiers stables, pour qu’un CMS puisse inclure <script src=".../main.js">
ng build atlas-widgets --output-hashing=none
# → publier dans des dossiers versionnés : /atlas-widgets/1.4.0/main.js
# (cache long, pas de hash dans le nom ; les chunks lazy gardent leur hash)
# mesuré avec Angular 22.2.2, un élément simple, zoneless, build de production :
# main.js ≈ 110 kB minifiés, ≈ 33 kB de transfert estimés par le CLI
# chaque bundle construit séparément apporte sa propre copie de
# @angular/core + @angular/platform-browserUtilisation depuis d’autres environnements
Pour du HTML statique, un <script type="module"> et la balise suffisent. Chacun des autres environnements a un petit détail de configuration :
- Angular : le composant hôte déclare
schemas: [CUSTOM_ELEMENTS_SCHEMA]. Les Bindings[prop]et(event)fonctionnent, mais sans vérification des types ; si vous êtes dans la même application, utilisez directement le composant. - React 19 prend entièrement en charge les custom elements : les props qui correspondent à une propriété de l’élément sont définies comme propriétés DOM (dans les versions antérieures, elles arrivaient comme attributs). Pour les événements personnalisés, l’option portable est un
ref+addEventListener. - Vue 3 doit savoir que la balise n’est pas un composant Vue : l’option de compilation
isCustomElement. - CMS : l’éditeur n’insère que du HTML, donc tout doit être configurable via des attributs de type string. Concevez les inputs comme des primitives ; pour les configurations complexes, utilisez un attribut JSON avec un transform qui appelle
JSON.parse.
// Utiliser un custom element (le vôtre ou celui d’une autre équipe) dans un composant Angular
import { CUSTOM_ELEMENTS_SCHEMA, Component, signal } from '@angular/core';
@Component({
selector: 'app-product',
// sans le schema : erreur « 'atlas-rating' is not a known element »
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<atlas-rating
[value]="rating()"
max-stars="5"
(ratingChange)="rating.set($any($event).detail.value)"
></atlas-rating>
`,
})
export class Product {
protected readonly rating = signal(4);
}
// [value] définit la propriété DOM, (ratingChange) fait un addEventListener,
// mais sans aucune vérification des types.Le contenu d’un custom element n’apparaît qu’après le téléchargement et l’exécution du script. Pour un texte essentiel au SEO ou au LCP (le titre de la page, le prix, le contenu principal), un widget côté client est un mauvais choix : voir SSR.
Communication entre applications
Quand plusieurs applications indépendantes cohabitent sur la même page (widgets d’équipes différentes, micro frontends), elles ne partagent pas d’injecteur. Il reste les mécanismes de la plateforme, dans l’ordre où il vaut la peine de les essayer :
| Mécanisme | Portée | Ce que vous pouvez envoyer | À surveiller |
|---|---|---|---|
| Attributs / propriétés + événements sur l’élément | l’hôte ↔ son élément | n’importe quoi (propriétés), detail (événements) | c’est le contrat public du widget : versionnez-le |
CustomEvent sur window | la même page | n’importe quel objet (une référence, pas une copie) | noms préfixés, le « late subscriber » ; ce n’est pas de l’état |
BroadcastChannel | onglets, fenêtres, iframes, workers de même origine | valeurs clonables (structured clone) | n’atteint pas l’expéditeur ; close() à la fin |
postMessage | iframe / window, y compris d’une autre origine | valeurs clonables | un targetOrigin explicite, vérifiez event.origin |
Un bus sur window est le contrat le plus simple entre applications d’une même page : noms d’événements préfixés (atlas:cart:add), types dans un petit package versionné, deux fonctions emit/listen. Le piège classique : un événement n’est pas de l’état, et une application montée plus tard a raté tout ce qui s’est passé avant. La solution est un handshake : au démarrage, on demande l’état, et son propriétaire répond.
// contracts/atlas-events.ts — un petit package versionné, partagé par les équipes
export interface AtlasEvents {
'atlas:cart:add': { sku: string; qty: number };
'atlas:cart:changed': { count: number; total: number };
'atlas:cart:state-request': { from: string };
}
export function emit<K extends keyof AtlasEvents>(type: K, detail: AtlasEvents[K]): void {
window.dispatchEvent(new CustomEvent(type, { detail }));
}
export function listen<K extends keyof AtlasEvents>(
type: K,
fn: (detail: AtlasEvents[K]) => void,
): () => void {
const handler = (e: Event) => fn((e as CustomEvent<AtlasEvents[K]>).detail);
window.addEventListener(type, handler);
return () => window.removeEventListener(type, handler);
}window (un troisième listener indépendant)- Ajoutez un produit depuis A.
BroadcastChannel relie tous les contextes de même origine qui ouvrent un canal du même nom : onglets, fenêtres, iframes, workers. Le message est copié par structured clone (pas de fonctions, pas d’instances de classe avec méthodes) et n’atteint pas l’objet qui l’a envoyé. Usages typiques : déconnexion dans tous les onglets, synchronisation du panier, invalidation d’un cache. Pour les iframes cross-origin, il reste postMessage, avec l’origine vérifiée aux deux extrémités.
- Ouvrez un second onglet et cliquez sur +1 dedans.
// Même origine : autres onglets, fenêtres, iframes, workers
@Injectable({ providedIn: 'root' })
export class SessionSync {
private readonly channel = new BroadcastChannel('atlas-session');
private readonly router = inject(Router);
constructor() {
this.channel.onmessage = (e: MessageEvent<{ type: 'logout' }>) => {
if (e.data?.type === 'logout') this.router.navigateByUrl('/login');
};
inject(DestroyRef).onDestroy(() => this.channel.close());
}
logoutEverywhere(): void {
// atteint les autres onglets, PAS cet objet
this.channel.postMessage({ type: 'logout' });
}
}Quand OUI, quand NON
- Des widgets pour des pages que vous ne contrôlez pas : un CMS (WordPress, AEM, Sitecore), des sites de clients, un chat de support, un configurateur de produit.
- Un design system utilisé depuis plusieurs frameworks, quand son équipe écrit en Angular.
- Des îlots interactifs dans une application côté serveur (PHP, Java, .NET) qui ne sera pas réécrite de sitôt.
- Des micro frontends qui ne peuvent pas partager la même version d’Angular : chaque élément apporte son runtime, les versions peuvent donc différer.
- Du HTML dynamique (venant d’un CMS, de markdown) inséré avec
innerHTMLdans une application Angular, où des composants ordinaires ne démarreraient pas.
- Dans la même application Angular → des composants ordinaires (ou des composants dynamiques avec
createComponent/NgComponentOutlet). - Un contenu critique pour le SEO ou le LCP → rendu côté serveur (SSR, SSG), pas un widget côté client.
- Plusieurs applications Angular de même version qui doivent partager le runtime → Native Federation, pas N bundles séparés.
- Seulement pour réutiliser du code entre les applications Angular d’une même équipe → une bibliothèque dans un monorepo.
- De minuscules widgets sur des pages sensibles au poids (un bouton « partager ») → 33 kB de runtime ne se justifient pas ; un web component écrit à la main ou Lit conviennent mieux.
Pièges fréquents
- Le selector du composant utilisé comme balise → deux instances sur le même élément. Utilisez un selector distinct (
atlas-rating-cmp). define()appelé deux fois (HMR, tests, deux bundles qui définissent la même balise) →NotSupportedError. VérifiezcustomElements.get(); entre équipes, préfixez les balises avec le nom du produit.- Attendre que les événements remontent → un listener sur
documentqui ne reçoit rien. Écoutez sur l’élément, ou réémettez explicitement avecbubbles: true, composed: true. - Des outputs nommés
change,input,click→ ils se mélangent avec les événements natifs des éléments internes qui atteignent l’hôte. Utilisez des noms spécifiques (ratingChange). - Un input numérique sans transform → l’attribut arrive en string et
maxStars() + 1devient'71'. MetteznumberAttribute/booleanAttributesur chaque input configurable depuis le HTML. - Des objets via des attributs → le composant reçoit
"[object Object]". Envoyez les objets et tableaux comme propriétés. - Attendre de l’élément qu’il voie les services du composant parent → il utilise l’injecteur donné à
createCustomElement, pas l’endroit du DOM où vous l’avez placé. Les services desprovidersd’un composant ou d’une route ne l’atteignent pas. - Des enfants ajoutés après la création → les nœuds pour
<ng-content>ne sont collectés qu’une fois, à la connexion. Le contenu dynamique passe par des propriétés. - Réutiliser un élément détruit → HTML mort,
NG0953, puisNotSupportedError. Créez un nouvel élément. - Des listeners et canaux non fermés → après chaque montage, un
window.addEventListenerde plus ou unBroadcastChannelouvert de plus. Nettoyez dansDestroyRef.onDestroy. postMessage(data, '*')et aucune vérification deevent.origin→ toute page qui vous charge dans une iframe peut lire ou envoyer des messages. Voir Sécurité.
Aide-mémoire
// 1. Le composant : des inputs avec transform pour les attributs, des outputs aux noms spécifiques
readonly maxStars = input(5, { transform: numberAttribute }); // <x max-stars="7">
readonly ratingChange = output<RatingChange>(); // CustomEvent, detail
// 2. Définition : une seule fois, balise ≠ selector, balise avec un tiret
createApplication({ providers: [] }).then(({ injector }) => { // bundle séparé
// (dans une application existante : const injector = inject(ApplicationRef).injector)
if (!customElements.get('atlas-rating')) {
customElements.define('atlas-rating', createCustomElement(AtlasRating, { injector }));
}
});
// 3. Utilisation
el.setAttribute('max-stars', '7'); // string → transform
el.maxStars = 7; // n’importe quel type
el.addEventListener('ratingChange', fn); // sur l’élément : bubbles false, composed false
// 4. Cycle de vie : append = création (synchrone) ; remove = destroy après 10 ms ;
// ne rattachez pas une instance détruite → créez un autre élément
// 5. Styles : ViewEncapsulation.ShadowDom + variables CSS + ::part() pour le thème
// 6. Communication : attributs/propriétés/événements → CustomEvent sur window (typé)
// → BroadcastChannel (onglets, même origine) → postMessage + origine (iframes)