Crear componentes con ‘themes’. CSS & :host-context()

Recientemente me encontré en una conversación con un diseñador sobre los problemas que había cuando había que estilar componentes anidados.

Microcomponentes

El problema de los microcomponentes es que se repiten en muchos sitios, con lógica nula o exactamente igual, pero con una cantidad muy alta de variaciones de estilo, según la página dónde estemos utilizándolo.
No es extraño avanzar en el desarrollo, especialmente en proyectos de cierto calibre, y encontrarte con nuevas partes de “aspecto misteriosamente sospechoso” a algo que diseñaste semanas atrás. Es el mismo componente, pero cambia algún estilo en función del contexto donde esté…

¿Qué hacer?

Las opciones adoptadas por la comunidad solían pasar por estilar todo desde el componente contenedor con //deep o usar ViewEncasulation.none en el microcomponente. A la mala, se pueden crear extensiones de un microcomponente sólo para cambiar la hoja de estilos. (DRY, uhm…?)

Antes de profundizar en el tema de :host-context y su alcance, repasemos un poco porqué estas soluciones invitan a malas prácticas, hasta el punto de que una de ellas ha pasado a deprecated.

//deep y ViewEncapsulation.none : El problema de la encapsulación

ViewEncapsulation.none hace que el css del componente pase a la la hoja de estilos global.

Esto da lugar a una hoja de estilos global abultada, y los estilos ya no se limitan al componente donde son definidos. Esto provoca un cierto caos post-compilado en el que se dan conflictos de clases css con mucha facilidad.

::ng-deep //deep (deprecated)

::ng-deep permite estilar contenido que no está presente directamente en el template del componente, pero sí es parte interna de alguno de sus elementos.

Es decir, podemos estilar contenido propio de un componente desde su padre. A primera vista es un ¿por qué no…? pero si nos detenemos a reflexionar un poco en qué es un componentes veremos que es una mala práctica. Rompe el principio de encapsulación.

Principio de encapsulación:

Cada componente debería ser responsable en la medida de lo posible de su estilo y su lógica.

¿Tiene sentido que el componente abuelo tenga estilos en su hoja que corresponden a elementos del componente hijo, o del nieto? Ese contenido ni siquiera está presente en su template. No lo tiene.
Además, el desarrollador que abra el componente nieto, no verá estilos propios en la hoja del componente, pero... ¿Dé donde vienen? Se hace difícil de trackear.

Tener un componente encapsulado no sólo es más limpio, sino más fácil de escalar, mantener y testear. Responsabilidad única.

Ahora, para dar solución a la pregunta… ¿Y cómo sabe el microcomponente cúando cambiar de estilo? Mirando en sus ancestros con :host-context().

Del abuelo al componente nieto : Un accidente feliz con :host-context() 😮

Hasta la fecha venía utilizando :host-context() sólo para aplicar una clase al componente desde el padre, y modificar el CSS del hijo sin necesidad de utilizar NgClass y bajar un @Input.

Esto me permitía, por ejemplo, diseñar componentes que tuviesen dos tamaños. Y mostraba uno u otro, según una clase aplicada al selector del componente cuando lo inicias en el padre.
Pero, por accidente, descubrí durante el desarrollo de un módulo de varios niveles de anidamiento que…

usando :host-context() se puede aplicar un estilo en el nieto, en función de una clase aplicada en el componente abuelo!

Hora de investigar y hacer experimentos.

Investigación MDN

:host-context() es un pseudoselector CSS, no un artefacto Angular.
Hasta hace poco pensaba que :host() y :host-context() eran artefactos de Angular. En realidad, son pseudoselectores de acceso a la shadow-dom, incorporados en los navegadores. Es decir, puede crearse un html y css a pelo y utilizar dichos métodos sin necesidad de framework.

¿Qué hace :host-context()?

Tests whether there is an ancestor, outside the shadow tree, which matches a particular selector.

Esencialmente va comprobando desde el padre del componente donde defines esa regla, pasando por todos los ancestros directos (padre-abuelo, bisa….) hasta que encuentre un match del selector CSS pasado como argumento al :host-context()

Resumen de experimentos:

  1. Host() y host-context() son pseudoselectores CSS, no artefactos Angular.
  2. El context no es sólo el host, o selector único del componente, sino cualquier ancestro directo del componente en la DOM.
  3. Puede aplicarse a cualquier elemento HTML nativo, no sólo a un componente.
  4. Las reglas se resuelven por cascada, y especificidad.
  5. Es necesario pararse a pensar dónde se origina el contexto. (Normalmente, a nivel card o el componente contenedor).
  6. Pueden utilizarse selectores de atributo para que el estilo de un componente de los niveles bajos reaccione al estado de uno en niveles superiores.

Los experimentos 🔬

Experimento 1: Aplicar clase .sm en bisabuelo, y ver si aplica en nieto.
✅ La regla de :host-context(.sm) en el css del nieto se aplica.

Experimento 2: ¿Qué pasa con el router-outlet, si le pongo la clase?
❌ No aplica el css.

No lo aplica porque el router-outlet actúa de simple placeholder. En la DOM el componente proyectado y el router-outlet van en paralelo, son hermanos. Al no ser ancestro directo, no aplica la regla.

Experimento 2.5: Wrap de router-outlet con un section + clase.

✅ Aplica el css al nieto. No tiene porqué ser necesariamente un componente, se podría aplicar la clase a cualquier elemento nativo html, con tal de que sea ancestro en el árbol.

Experimento 4: ¿Hace cascada?
✅ Si. Y da igual si la clase se aplica en el abuelo, el bisuabuelo o al renacuajo de la sopa primitiva. Las reglas que aplican se resuelven por cascada y especificidad en la hoja de estilos del nivel donde se usa :host-context().
Igual que sucede con otros selectores, en caso de reglas de misma especificidad (los dos son selectores de una clase), gana el que esté más abajo.

aquí gana .md
aquí gana .sm

Experimento 5: Host-context dentro de host-context.

❌ No aplica más de un :host-context anidado.

Problema potencial de :host-context(): Aplicar reglas no deseadas.

Pongamos el escenario:
Componente abuelo: clase .sm
Componente padre: nada.
Componente hijo: regla :host-context(.sm) aplicada.

En este caso se está aplicando la regla :host-context(.sm) al hijo, sin que el padre le indique nada explícitamente. Lo que puede llegar a aplicar reglas no deseadas. En mi caso, fue un accidente afortunado!

¿La solución al problema? Pararse a pensar cúal es el origen del contexto, y definir selectores más específicos a partir de ese elemento.

Normalmente, el wrapper o contenedor que rodea todo, es el que suele condicionar el resto de los estilos de los elementos. Del mismo modo que suele ser el sitio donde se inyectan dependencias para pasarlas a niveles inferiores, aquí es buen lugar para marcar el origen de un theme, y crear reglas con :host-context() que partan de ese nivel que ‘provoca’ el cambio.

Un Típico caso de microcomponente. Un detalle, dentro de un card-content, dentro de un card-component.

No es necesario crear un montón de clases con BEM, basta con apuntar a los componentes. Recordemos que tienen selectores únicos. Al mantener la encapsulación, podemos reservar las clases para matizar detalles específicos que en otro momento hubiésemos marcado como !important, dejando una hoja de estilos más limpia, y un template menos sobrecargado de clases.

Bonus point: Cambiar CSS del nieto, en función del estado del componente padre.

Propiedades estáticas
Es necesario que la propiedad que usemos para el estado esté definida en el template, no sólo en el .ts del componente. Con eso, y un selector de atributo podemos aplicar una regla.

Si es una variable estática, puede accederse directamente.

Propiedades dinámicas
Pongamos que tengo un botón con el que puedo hacer toggle de la propiedad isAGoodParent

En el caso de un binding de propiedad ([input]=”variable”) Angular autogenera al compilar la propiedad ng-reflect y transforma la variable del formato camelCase a separación por guiones.

Para acceder a la variable con [property binding] en el CSS es necesario utilizar ng-reflect

Ahora la regla se aplicará o no en función de cambios en la variable que está a nivel de abuelo!

Después de la investigación no se puede negar que :host-context() no sea una herramienta muy a tener en cuenta a la hora de diseñar componentes reutilizables con diferentes themes. Con buenas prácticas, mantiene los estilos aislados en el componente, mientras permite dar cierta flexibilidad en función del contexto donde se utilicen.

¿Qué opinas tú…?

Coffee lover. Psychologist. Nerdy Front-End Developer since the 56-Kbps days. Javascript & Angular enthusiast. | Writer at Angular Playground

Coffee lover. Psychologist. Nerdy Front-End Developer since the 56-Kbps days. Javascript & Angular enthusiast. | Writer at Angular Playground