Angelitedocs ESEN

Inicio › Aprender

Componentes

Cómo se escribe un componente: inputs, outputs, slots, portales, referencias, límites de error y servicios con ámbito.

Un componente#

Un componente es una clase de JavaScript más una llamada a component(Clase, meta). La clase guarda el estado (señales) y los métodos; meta dice cómo se llama la etiqueta (selector), cuál es la plantilla y qué otros componentes usa (imports).

  • Inputs: campos creados con input(valorInicial). Son señales escribibles que el padre rellena con [nombre]="expr" o con un atributo normal (nombre="texto").
  • Outputs: campos creados con output(). El hijo llama a emit(valor) y el padre escucha con (nombre)="..."; el valor llega como $event.
  • Ciclo de vida: ngOnInit() (antes de pintar), ngAfterViewInit() (ya en el DOM) y ngOnDestroy() (al quitarlo). this.$el es el elemento anfitrión.
  • Métodos: se pueden pasar sueltos a la plantilla ((click)="guardar()"): ya están ligados a la instancia.
Componente hijo con input, output y ng-contenten vivo
Ver el código
const { signal, component, mount, input, output } = AngeLite;

class Card {
  title = input('');
  closed = output();
  close() { this.closed.emit('cerrada'); }
}
component(Card, {
  selector: 'ui-card',
  template: `
    <div class="card">
      <b>{{ title() }}</b> <button (click)="close()">×</button>
      <div><ng-content></ng-content></div>
    </div>`
});

class App {
  name = signal('Ada');
  last = signal('—');
  log(e) { this.last.set(e); }
}
component(App, {
  imports: [Card],
  template: `
    <ui-card [title]="'Perfil de ' + name()" (closed)="log($event)">
      Contenido proyectado: {{ name() }}
    </ui-card>
    <p>Último evento: {{ last() }}</p>`
});

mount(App, root);
Las etiquetas personalizadas no se auto-cierran

En HTML escribe siempre <ui-card></ui-card>, no <ui-card />: el navegador ignora la barra y se «come» lo que siga.

Con TypeScript o decoradores

@Component({ selector, template }) hace lo mismo que component() si tu herramienta admite decoradores. Los tipos (Signal<T>, OutputEmitter<T>, OnInit…) vienen en el paquete: mira la referencia.

Slots con nombre#

El contenido que pones entre las etiquetas de un componente se proyecta donde esté <ng-content>. Si necesitas varias zonas, el componente declara <ng-content select="titulo"> y quien lo usa marca los hijos con slot="titulo". Lo que no lleva slot va a la zona por defecto. El contenido se evalúa en el ámbito del padre y sigue siendo reactivo.

Slots con nombreen vivo
Ver el código
const { signal, component, mount } = AngeLite;

class Panel {}
component(Panel, {
  selector: 'ui-panel',
  template: `
    <div class="card">
      <header><ng-content select="titulo"></ng-content></header>
      <section><ng-content></ng-content></section>
      <footer><small><ng-content select="pie"></ng-content></small></footer>
    </div>`
});

class App {
  n = signal(1);
}
component(App, {
  imports: [Panel],
  template: `
    <ui-panel>
      <b slot="titulo">Pedido #{{ n() }}</b>
      Cuerpo del panel
      <span slot="pie">{{ n() }} artículo(s)</span>
    </ui-panel>
    <button (click)="n.set(n() + 1)">+1 artículo</button>`
});

mount(App, root);

Portales#

<ng-portal to="#destino"> pinta su contenido dentro de otro elemento (por defecto document.body) pero lo mantiene en el ámbito del componente: sirve para modales, menús y tooltips que no deben quedar atrapados por un overflow o un z-index. Se limpia solo al destruir el bloque. Está en el paquete principal y en runtime-plus.

Portal hacia otro contenedoren vivo
Ver el código
const { signal, component, mount } = AngeLite;

const target = document.createElement('div');
target.style.cssText = 'margin-top:10px;padding:10px;border:1px dashed currentColor';
target.id = 'portal-destino';
target.textContent = 'Destino del portal: ';
root.after(target);

class App {
  open = signal(false);
}
component(App, {
  template: `
    <button (click)="open.set(!open())">{{ open() ? 'Cerrar' : 'Abrir' }} aviso</button>
    @if (open()) {
      <ng-portal to="#portal-destino"><b>¡Hola desde otro sitio del DOM!</b></ng-portal>
    }`
});

mount(App, root);

Referencias a elementos#

Marca un elemento con #nombre y recíbelo con viewChild('nombre'): una señal con el elemento (o la instancia del componente, si lo pones en una etiqueta de componente) que vuelve a null cuando sale del DOM. Es la forma de enfocar un campo, medir un elemento o llamar a un método del hijo.

viewChilden vivo
Ver el código
const { component, mount, viewChild } = AngeLite;

class App {
  campo = viewChild('campo');
  enfocar() { this.campo().focus(); }
}
component(App, {
  template: `
    <input #campo placeholder="Escribe aquí">
    <button (click)="enfocar()">Enfocar el campo</button>`
});

mount(App, root);

Errores controlados#

Si un componente define ngOnError(error, dónde), recibe los errores de sus bindings y handlers y los del contenido que le proyectan. Con eso se hace un límite de error: muestra una alternativa en lugar de dejar un hueco. Sin ngOnError el error se registra en la consola y el resto de la vista sigue funcionando.

Un límite de erroren vivo
Ver el código
const { signal, component, mount } = AngeLite;

class Limite {
  fallo = signal(null);
  ngOnError(error) { this.fallo.set(error.message); }
}
component(Limite, {
  selector: 'error-limite',
  template: `
    @if (fallo()) {
      <p class="card">Algo salió mal: <b>{{ fallo() }}</b></p>
    } @else {
      <ng-content></ng-content>
    }`
});

class App {
  roto() { throw new Error('dato inválido'); }
}
component(App, {
  imports: [Limite],
  template: `
    <error-limite><p>{{ roto() }}</p></error-limite>
    <p>El resto de la página sigue funcionando.</p>`
});

mount(App, root);
Qué captura y qué no

Captura errores de expresiones, bindings y handlers de eventos. No captura los que se lanzan en el constructor ni en ngOnInit del propio componente: esos se propagan a quien lo montó.

Servicios e inyección de dependencias#

inject(Clase) devuelve una instancia compartida de la clase (se crea al primer uso): sirve para servicios, estados globales y clientes de API. Con provide(Clase, valor) la sustituyes, por ejemplo en un test.

Servicio compartido entre componentesen vivo
Ver el código
const { signal, component, mount, inject } = AngeLite;

class CartService {
  items = signal(0);
  add() { this.items.update(n => n + 1); }
}

class AddButton {
  cart = inject(CartService);
}
component(AddButton, {
  selector: 'add-button',
  template: `<button (click)="cart.add()">Añadir al carrito</button>`
});

class Badge {
  cart = inject(CartService);
}
component(Badge, {
  selector: 'cart-badge',
  template: `<b>🛒 {{ cart.items() }}</b>`
});

class Shop {}
component(Shop, {
  imports: [AddButton, Badge],
  template: `<add-button></add-button> <cart-badge></cart-badge>`
});

mount(Shop, root);

Servicios con ámbito: providers#

Si un componente declara providers, el servicio es suyo y de sus descendientes, y cada instancia del componente tiene el suyo. Un proveedor es la clase, o { provide: Api, useClass | useValue | useFactory }. inject() busca primero en el componente más cercano que lo declare y, si ninguno lo hace, usa la instancia compartida. Funciona dentro del constructor y de los inicializadores de campo.

providers: un servicio por componenteen vivo
Ver el código
const { signal, component, mount, inject } = AngeLite;

class Contador {
  n = signal(0);
  sumar() { this.n.update(v => v + 1); }
}

class Boton {
  c = inject(Contador);              // el del componente más cercano que lo declare
}
component(Boton, { selector: 'mi-boton', template: `<button (click)="c.sumar()">sumar ({{ c.n() }})</button>` });

class Grupo {}
component(Grupo, {
  selector: 'mi-grupo',
  providers: [Contador],             // cada <mi-grupo> tiene su propio Contador
  imports: [Boton],
  template: `<mi-boton></mi-boton> <mi-boton></mi-boton>`
});

class App {}
component(App, { imports: [Grupo], template: `<p>Grupo A: <mi-grupo></mi-grupo></p><p>Grupo B: <mi-grupo></mi-grupo></p>` });

mount(App, root);