Un custom element (Web Component) e un tag HTML definit de tine — <atlas-rating> — pe care browserul îl recunoaște oriunde: într-o pagină statică, într-un CMS, într-o aplicație React sau într-o altă aplicație Angular. @angular/elements face puntea: createCustomElement() transformă o componentă Angular într-o clasă care extinde HTMLElement. Input-urile devin atribute și proprietăți, output-urile devin CustomEvent-uri, iar componenta se creează singură când tag-ul intră în DOM.
Exemplele de pe pagină sunt reale: definim elementele la runtime cu customElements.define, le creăm cu document.createElement într-un container pe care Angular nu-l gestionează și le ascultăm cu addEventListener — exact cum ar face o pagină care nu știe nimic despre Angular. Totul rulează în aplicația asta, care e zoneless.
createCustomElementtransformă o componentă într-un Web Component, folosibil în orice pagină: React, Vue, un CMS sau HTML simplu.- Input-urile devin proprietăți și atribute (în kebab-case), iar output-urile devin evenimente
CustomEvent. - Cu Shadow DOM, stilurile elementului și ale paginii nu se mai amestecă.
- Fiecare element aduce runtime-ul Angular. Mai multe elemente pe aceeași pagină ar trebui să vină din același bundle.
- Merită pentru widget-uri incluse în pagini pe care nu le controlezi, nu pentru o aplicație Angular obișnuită.
Angular Elements e unealta potrivită când o componentă trebuie să trăiască în afara unei aplicații Angular: widget-uri pentru un CMS sau pentru site-urile clienților, un design system folosit de echipe pe framework-uri diferite, micro frontends care nu pot împărți aceeași versiune de Angular. În interiorul unei aplicații Angular obișnuite e aproape mereu alegerea greșită: pierzi verificarea de tipuri în template, ierarhia de DI și proiecția de conținut dinamică.
Ce face createCustomElement
createCustomElement(Component, { injector }) nu afișează nimic. Întoarce o clasă (NgElementConstructor) pe care o înregistrezi cu customElements.define(tag, clasa). Clasa citește metadatele componentei (reflectComponentType) și pregătește trei lucruri:
observedAttributes— numele input-urilor în kebab-case (maxStars→max-stars), ca browserul să-i anunțe fiecare schimbare de atribut;- un getter și un setter pe prototip pentru fiecare input, deci
el.maxStars = 7funcționează ca o proprietate DOM obișnuită; - o strategie (
NgElementStrategy) care, laconnectedCallback, apeleazăcreateComponentcu elementul cahostElement, atașează view-ul laApplicationRefși rulează un primdetectChanges()— sincron, înainte caappend()să se întoarcă.
Injectorul pe care îl dai e locul din care componenta își ia serviciile (HttpClient, store-uri, token-uri de configurare). Într-un bundle dedicat widget-urilor îl obții din createApplication(), o aplicație fără componentă root; într-o aplicație existentă, din 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', // ≠ numele tag-ului (vezi mai jos)
encapsulation: ViewEncapsulation.ShadowDom, // CSS-ul paginii gazdă nu intră
template: `
@for (s of stars(); track s) {
<button type="button" [class.on]="s <= current()" [disabled]="readonly()"
(click)="pick(s)">★</button>
}
`,
})
export class AtlasRating {
// atribute: value, max-stars, readonly
readonly value = input(0, { transform: numberAttribute });
readonly maxStars = input(5, { transform: numberAttribute });
readonly readonly = input(false, { transform: booleanAttribute });
// devine CustomEvent 'ratingChange', valoarea în event.detail
readonly ratingChange = output<RatingChange>();
// stare locală, resetată când gazda trimite altă valoare
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 });
}
}Ghidul oficial cere explicit: nu folosi selectorul componentei ca nume de tag. Dacă atlas-rating e și selector, și custom element, iar componenta e importată și într-un template, Angular creează două instanțe pe același element. De aici convenția atlas-rating-cmp (selector) vs. atlas-rating (tag). Tag-ul trebuie să conțină o cratimă, iar define() pe un nume deja înregistrat aruncă NotSupportedError — de aceea verifici întâi cu customElements.get().
Zone.js nu e necesar. Din v19 elementele folosesc scheduler-ul obișnuit de change detection (changelog 19.0.0: „switch to ComponentRef.setInput & remove custom scheduler”), iar din v21 aplicațiile sunt zoneless implicit. Un setAttribute ajunge la componentRef.setInput(), care marchează view-ul și programează o randare; un click în template-ul elementului schimbă un semnal — exact ca într-o componentă OnPush obișnuită.
Definire într-o aplicație existentă
Cazul tipic: aplicația primește HTML dintr-un CMS și îl inserează cu innerHTML. Componentele Angular din acel HTML nu ar porni niciodată, dar custom elements pornesc singure, pentru că browserul le recunoaște tag-ul.
// Într-o aplicație existentă (ex. HTML din CMS, inserat cu 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; // root: trăiește cât aplicația
// define() pe un nume deja înregistrat aruncă NotSupportedError
if (!customElements.get('atlas-rating')) {
customElements.define('atlas-rating', createCustomElement(AtlasRating, { injector }));
}
}
export const appConfig = {
providers: [provideAppInitializer(defineAtlasElements)],
};Atribute, proprietăți, evenimente
Un element are două „intrări” pentru același input. Atributul (max-stars="7") e mereu un string — de aceea input-urile numerice sau booleene au nevoie de transform: numberAttribute / booleanAttribute; fără el, maxStars() + 1 devine '71'. Proprietatea (el.maxStars = 7) primește orice tip, deci obiectele și array-urile trec doar pe aici. La citire, el.maxStars întoarce valoarea curentă a input-ului; din v21 asta e valabil și pentru input() pe semnale (înainte trebuia apelat el.maxStars()).
| Input / output | Atribut HTML | Proprietate JS | De reținut |
|---|---|---|---|
maxStarstransform: numberAttribute | max-stars="7" | el.maxStars = 7 | atributul vine ca '7', transform-ul îl face număr |
readonlytransform: booleanAttribute | readonly | el.readonly = true | removeAttribute trimite null → false |
userIdalias: 'user' | user="42" | el.userId | atributul folosește alias-ul, proprietatea — numele din clasă |
itemsinput<Item[]> | — | el.items = [...] | prin atribut ar ajunge "[object Object]" |
ratingChangeoutput<T>() | — | addEventListener('ratingChange', …) | CustomEvent, valoarea în event.detail |
export class Widget {
// atributul = numele input-ului (sau alias-ul) în 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[]>([]); // doar proprietate
// un output devine CustomEvent cu numele output-ului (sau al alias-ului)
readonly ratingChange = output<RatingChange>(); // 'ratingChange'
readonly closed = output<void>({ alias: 'atlasClosed' }); // 'atlasClosed'
}
// Ce generează createCustomElement pentru clasa de mai sus:
// static observedAttributes = ['max-stars', 'compact', 'user', 'items']
// getter/setter pe prototip: maxStars, compact, userId, items
// (proprietatea folosește numele din clasă, atributul folosește alias-ul)Evenimentul e creat cu new CustomEvent(nume, { detail }) — fără bubbles și fără composed. Un listener pe document sau pe containerul părinte nu primește nimic; asculți direct pe element. În exemplul de mai jos containerul are și el un listener pentru ratingChange: n-o să apară niciodată în log.
// apasă un buton sau schimbă un câmpel.value: —- 09:11:45 customElements.define('atlas-rating', …) — definit acum
Observă un detaliu: după ce dai click pe o stea, el.value rămâne valoarea primită de la gazdă. Input-ul e un canal într-un singur sens; componenta ține alegerea într-un linkedSignal și o anunță prin eveniment. Dacă gazda vrea să fie sursa adevărului, ascultă evenimentul și scrie înapoi el.value — modelul „controlled component”.
// Evenimentul e creat ca new CustomEvent(name, { detail })
// → bubbles: false, composed: false.
el.addEventListener('ratingChange', (e: Event) => {
const { value } = (e as CustomEvent<RatingChange>).detail;
});
document.addEventListener('ratingChange', handler); // ✗ nu primește nimic
// Dacă gazda chiar are nevoie de delegare (un listener pentru 50 de widget-uri),
// emite explicit, din componentă, un eveniment care urcă și trece de 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 }),
);
}
}Ciclul de viață
Componenta se naște la connectedCallback și moare la disconnectedCallback, dar nu imediat: strategia programează destroy() peste 10 ms (DESTROY_DELAY = 10 în sursa pachetului). Dacă elementul revine în DOM înainte — o mutare remove() + append() în același task, ca la sortarea unei liste — distrugerea se anulează și starea internă rămâne. Input-urile setate înainte de conectare sunt ținute minte și aplicate la creare. Mai mult, proprietățile setate pe un element încă nedefinit (scriptul widget-ului nu s-a încărcat) sunt preluate la upgrade, deci gazda poate seta el.items oricând.
document.createElement('atlas-rating')
constructor; încă nicio componentă (input-urile setate acum sunt ținute minte)
container.append(el)
connectedCallback → createComponent(...) SINCRON, attachView(appRef), detectChanges()
el.setAttribute('value', '4')
attributeChangedCallback → componentRef.setInput('value', '4') → transform → CD
el.value = 4
setter pe prototip → același drum, fără conversie la string
el.remove()
disconnectedCallback → destroy PROGRAMAT peste 10 ms
↳ re-adăugat în < 10 ms (o mutare) destroy anulat: aceeași instanță, aceeași stare
↳ după 10 ms componentRef.destroy() → DestroyRef.onDestroy
container.append(el) // aceeași instanță, după destroy
⚠︎ componenta NU se recreează: HTML mort + NG0953 în consolă
document.createElement('atlas-rating')
✓ instanță nouă, curatăGhidul oficial avertizează (secțiunea Limitations, issue #38778) că detașarea și re-atașarea unui element creat cu @angular/elements e problematică. În v22.2.2, comportamentul se vede în demo: după ce componenta a fost distrusă, un nou append al aceleiași instanțe nu o mai recreează. HTML-ul rămas în shadow root e mort (click-urile nu fac nimic), el.value e undefined, iar Angular raportează NG0953 (abonare la un OutputRef distrus). O a doua încercare aruncă NotSupportedError din attachShadow, pentru că un element nu poate avea două shadow roots.
Apare când cineva reutilizează nodul DOM: ng-if din AngularJS, un editor de CMS care mută blocuri, un cache de elemente în codul tău. Soluția: nu reutiliza instanța — creează alt element cu document.createElement — sau, dacă doar vrei să-l ascunzi, lasă-l în DOM cu atributul hidden.
Shadow DOM și stiluri
Cu Emulated (implicit), Angular rescrie selectorii componentei cu atribute unice: stilurile tale nu ies, dar CSS-ul paginii intră. Un widget pus pe un site străin moștenește orice regulă de tipul .content p sau button { text-transform: uppercase !important } — cel mai frecvent bug la widget-uri. Cu ViewEncapsulation.ShadowDom, template-ul se randează într-un shadow root (mod open), iar selectorii paginii nu mai văd înăuntru. Trec doar proprietățile moștenite (font, color) și variabilele CSS — exact ce îți trebuie pentru o temă controlată.
O nuanță puțin cunoscută: în modul ShadowDom, Angular copiază în fiecare shadow root și stilurile celorlalte componente din aceeași aplicație. Pentru componentele Emulated e doar balast (selectorii lor nu se potrivesc), dar o componentă cu ViewEncapsulation.None ajunge să stilizeze și interiorul widget-ului. ExperimentalIsolatedShadowDom (experimental din v21) pune în shadow root doar stilurile componentei. La SSR, ambele moduri shadow sunt randate ca Emulated.
| Encapsulare | CSS-ul paginii intră? | Stiluri None din aplicație | Variabile CSS |
|---|---|---|---|
Emulated (implicit) | da | da | da |
ShadowDom | nu | da (copiate în shadow root) | da |
ExperimentalIsolatedShadowDom | nu | nu | da |
| Element | el.shadowRoot | <style> în shadow root |
|---|
Shadow DOM schimbă mai mult decât stilurile: evenimentele care ies din shadow root sunt retargetate (event.target devine host-ul), document.querySelector nu găsește nimic din interior, iar testele e2e trebuie să străpungă shadow root-ul. Pentru temă, expune variabile CSS și, unde e nevoie de mai mult control, atribute part stilizabile cu ::part().
@Component({
selector: 'atlas-card-cmp',
encapsulation: ViewEncapsulation.ShadowDom,
template: `
<h3 part="title">{{ title() }}</h3>
<ng-content />
`,
styles: `
:host { display: block; }
/* variabilele CSS și proprietățile moștenite (font, color) TREC în shadow root */
h3 { color: var(--atlas-accent, #2b45d4); font: inherit; font-weight: 650; }
`,
})
export class AtlasCard {
readonly title = input('');
}Build, bundle, distribuție
Ca să folosești elementele în afara unei aplicații Angular, ai nevoie de un build separat: o aplicație al cărei main.ts apelează createApplication() și customElements.define(), fără componentă root. Schematic-ul Native Federation are chiar opțiunea --webcomponent, care generează exact acest bootstrap (vezi Micro frontends).
Costul e runtime-ul. Un singur element simplu, construit cu Angular 22.2.2 în producție, zoneless, iese ≈ 110 kB minificat, ≈ 33 kB transferați (estimarea CLI) — practic @angular/core + platform-browser + un pic de RxJS. Trei widget-uri din trei build-uri diferite pe aceeași pagină înseamnă trei copii ale runtime-ului, trei injectoare și trei aplicații care nu împart niciun serviciu.
- Grupează widget-urile unei echipe într-un singur bundle: un
createApplication(), mai multedefine(). - Nume stabile pentru integrare prin
<script src>:--output-hashing=noneși foldere versionate (/1.4.0/main.js), ca să poți pune cache lung fără hash în nume. - Lazy în interior:
@deferșiimport()funcționează și într-un element, deci partea grea a widget-ului poate veni la nevoie.
# un proiect separat în workspace, doar pentru widget-uri
ng generate application atlas-widgets --ssr=false
# main.ts: createApplication + customElements.define (vezi main.ts mai sus)
# nume de fișier stabile, ca un CMS să poată include <script src=".../main.js">
ng build atlas-widgets --output-hashing=none
# → publică în foldere versionate: /atlas-widgets/1.4.0/main.js
# (cache lung, fără hash în nume; chunk-urile lazy își păstrează hash-ul)
# măsurat cu Angular 22.2.2, un element simplu, zoneless, build de producție:
# main.js ≈ 110 kB minificat, ≈ 33 kB transfer estimat de CLI
# fiecare bundle construit separat își aduce propria copie de
# @angular/core + @angular/platform-browserConsumul din alte medii
Pentru HTML static ajunge un <script type="module"> și tag-ul. Celelalte medii au fiecare un mic detaliu de configurare:
- Angular: componenta gazdă declară
schemas: [CUSTOM_ELEMENTS_SCHEMA]. Binding-urile[prop]și(event)funcționează, dar fără verificare de tipuri — dacă ești în aceeași aplicație, folosește componenta direct. - React 19 are suport complet pentru custom elements: props-urile care corespund unei proprietăți a elementului sunt setate ca proprietăți DOM (în versiunile anterioare ajungeau ca atribute). Pentru evenimente custom, varianta portabilă e un
ref+addEventListener. - Vue 3 trebuie să știe că tag-ul nu e o componentă Vue: opțiunea de compilare
isCustomElement. - CMS-uri: editorul inserează doar HTML, deci totul trebuie să se poată configura prin atribute string. Proiectează input-urile ca primitive; pentru configurații complexe, un atribut JSON cu un transform care face
JSON.parse.
// Să folosești un custom element (al tău sau al altei echipe) într-o componentă Angular
import { CUSTOM_ELEMENTS_SCHEMA, Component, signal } from '@angular/core';
@Component({
selector: 'app-product',
// fără schema: eroare „'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] setează proprietatea DOM, (ratingChange) face addEventListener —
// dar fără nicio verificare de tipuri.Conținutul unui custom element apare abia după ce scriptul se descarcă și rulează. Pentru text esențial pentru SEO sau pentru LCP (titlul paginii, prețul, conținutul principal), un widget client-side e alegerea greșită — vezi SSR.
Comunicare între aplicații
Când pe aceeași pagină trăiesc mai multe aplicații independente (widget-uri ale unor echipe diferite, micro frontends), ele nu au un injector comun. Rămân mecanismele platformei, în ordinea în care merită încercate:
| Mecanism | Rază | Ce poți trimite | Atenție la |
|---|---|---|---|
| Atribute / proprietăți + evenimente pe element | gazda ↔ elementul ei | orice (proprietăți), detail (evenimente) | e contractul public al widget-ului: versionează-l |
CustomEvent pe window | aceeași pagină | orice obiect (referință, nu copie) | nume cu prefix, „late subscriber”, nu e stare |
BroadcastChannel | tab-uri, ferestre, iframe-uri, workers — aceeași origine | valori clonabile (structured clone) | nu ajunge la expeditor; close() la final |
postMessage | iframe / fereastră, inclusiv altă origine | valori clonabile | targetOrigin explicit, verifică event.origin |
Un bus pe window e cel mai simplu contract între aplicații de pe aceeași pagină: nume de evenimente cu prefix (atlas:cart:add), tipuri într-un pachet mic și versionat, două funcții emit/listen. Capcana clasică e că un eveniment nu e stare: o aplicație montată mai târziu a ratat tot ce s-a întâmplat înainte. Soluția e un handshake — la pornire ceri starea, iar proprietarul ei răspunde.
// contracts/atlas-events.ts — un pachet mic, versionat, împărțit de echipe
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 al treilea ascultător, independent)- Adaugă un produs din A.
BroadcastChannel leagă toate contextele de aceeași origine care deschid un canal cu același nume: tab-uri, ferestre, iframe-uri, workers. Mesajul e copiat prin structured clone (fără funcții, fără instanțe de clase cu metode) și nu ajunge la obiectul care l-a trimis. Cazuri tipice: logout în toate tab-urile, sincronizarea coșului, invalidarea unui cache. Pentru iframe-uri de altă origine rămâne postMessage, cu origine verificată la ambele capete.
- Deschide un al doilea tab și apasă +1 acolo.
// Aceeași origine: alte tab-uri, ferestre, iframe-uri, 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 {
// ajunge la celelalte tab-uri, NU și la acest obiect
this.channel.postMessage({ type: 'logout' });
}
}Când DA, când NU
- Widget-uri pentru pagini pe care nu le controlezi: CMS (WordPress, AEM, Sitecore), site-urile clienților, un chat de suport, un configurator de produs.
- Un design system folosit din mai multe framework-uri, când echipa lui scrie Angular.
- Insule interactive într-o aplicație server-side (PHP, Java, .NET) care nu se rescrie curând.
- Micro frontends care nu pot împărți aceeași versiune de Angular: fiecare element își aduce runtime-ul, deci versiunile pot diferi.
- HTML dinamic (din CMS, din markdown) inserat cu
innerHTMLîntr-o aplicație Angular, unde componentele obișnuite nu ar porni.
- În interiorul aceleiași aplicații Angular → componente obișnuite (sau componente dinamice cu
createComponent/NgComponentOutlet). - Conținut critic pentru SEO sau LCP → randare pe server (SSR, SSG), nu un widget client-side.
- Mai multe aplicații Angular de aceeași versiune care trebuie să împartă runtime-ul → Native Federation, nu N bundle-uri separate.
- Doar ca să refolosești cod între aplicațiile Angular ale aceleiași echipe → o librărie într-un monorepo.
- Widget-uri minuscule pe pagini sensibile la greutate (un buton „share”) → 33 kB de runtime nu se justifică; un web component scris de mână sau Lit e mai potrivit.
Capcane frecvente
- Selectorul componentei folosit ca tag → două instanțe pe același element. Folosește un selector separat (
atlas-rating-cmp). define()apelat de două ori (HMR, teste, două bundle-uri care definesc același tag) →NotSupportedError. VerificăcustomElements.get(); între echipe, prefixează tag-urile cu numele produsului.- Așteptarea că evenimentele urcă → listener pe
documentcare nu primește nimic. Ascultă pe element sau re-emite explicit cububbles: true, composed: true. - Output-uri numite
change,input,click→ se amestecă cu evenimentele native ale elementelor din interior care ajung la host. Folosește nume specifice (ratingChange). - Input numeric fără transform → atributul vine string și
maxStars() + 1devine'71'. PunenumberAttribute/booleanAttributepe orice input configurabil din HTML. - Obiecte prin atribute → componenta primește
"[object Object]". Obiectele și array-urile se trimit ca proprietăți. - Așteptarea că elementul vede serviciile componentei părinte → el folosește injectorul dat la
createCustomElement, nu locul din DOM unde l-ai pus. Serviciile dintr-unprovidersde componentă sau de rută nu ajung la el. - Copii adăugați după creare → nodurile pentru
<ng-content>se colectează o singură dată, la conectare. Conținutul dinamic se trimite prin proprietăți. - Reutilizarea unui element distrus → HTML mort,
NG0953, apoiNotSupportedError. Creează un element nou. - Listener-e și canale neînchise → după fiecare montare, încă un
window.addEventListenersau încă unBroadcastChanneldeschis. Cleanup înDestroyRef.onDestroy. postMessage(data, '*')și fără verificareaevent.origin→ orice pagină care te încadrează într-un iframe poate citi sau trimite mesaje. Vezi Securitate.
Fișă rapidă
// 1. Componenta: input-uri cu transform pentru atribute, output-uri cu nume specifice
readonly maxStars = input(5, { transform: numberAttribute }); // <x max-stars="7">
readonly ratingChange = output<RatingChange>(); // CustomEvent, detail
// 2. Definirea: o singură dată, tag ≠ selector, tag cu cratimă
createApplication({ providers: [] }).then(({ injector }) => { // bundle separat
// (într-o aplicație existentă: const injector = inject(ApplicationRef).injector)
if (!customElements.get('atlas-rating')) {
customElements.define('atlas-rating', createCustomElement(AtlasRating, { injector }));
}
});
// 3. Consumul
el.setAttribute('max-stars', '7'); // string → transform
el.maxStars = 7; // orice tip
el.addEventListener('ratingChange', fn); // pe element: bubbles false, composed false
// 4. Ciclul de viață: append = create (sincron); remove = destroy după 10 ms;
// nu re-atașa o instanță distrusă → creează alt element
// 5. Stiluri: ViewEncapsulation.ShadowDom + variabile CSS + ::part() pentru temă
// 6. Comunicare: atribute/proprietăți/evenimente → CustomEvent pe window (tipat)
// → BroadcastChannel (tab-uri, aceeași origine) → postMessage + origin (iframes)