Amb el mapa de la lliçó anterior ja traçat, aquesta lliçó comença a omplir-lo de veritat: el codi complet de <user-avatar>, <task-card> i <task-filter>, tal com queden al final del curs, cadascun en un únic fitxer coherent en lloc de repartit entre les lliçons que el van anar construint per parts. Cap fragment de codi d'aquesta lliçó introdueix una tècnica nova; cada bloc porta un comentari que assenyala a quina lliçó del curs es va explicar, i l'únic treball genuïnament nou és tancar un parell de peces que van quedar esmentades sense resoldre del tot, com la mateixa animació CSS de la classe resaltada que la directiva resaltarSiUrgente afegeix i treu, però l'aspecte visual de la qual mai no va arribar a definir-se.
Contingut
- Què significa "consolidar" en aquest mòdul
<user-avatar>, complet<task-card>complet: propietats, estat i controlador d'urgència<task-card>complet: interacció, esdeveniments i accessibilitat<task-card>complet: estils irender()final<task-filter>, complet- Quin mòdul va aportar cada peça: taula resum
- Conclusió cap a la gestió d'estat
- Què significa "consolidar" en aquest mòdul
Cadascun dels tres components d'aquesta lliçó va aparèixer, al llarg del curs, en fragments successius: una lliçó el creava amb el mínim indispensable, i lliçons posteriors, centrades en una tècnica diferent, li anaven afegint peces sense tornar a mostrar el fitxer complet cada vegada (per no repetir, a cada lliçó, codi ja explicat a l'anterior). El resultat és correcte, però està distribuït entre nou mòduls diferents. Consolidar significa, aquí, dues coses alhora: unir totes aquestes peces en un únic fitxer per component, i aprofitar la vista de conjunt per completar els pocs detalls que van quedar només esbossats, com l'animació de resaltada de l'apartat 5.
<user-avatar>, complet
<user-avatar>, complet<user-avatar> és el component més petit de TaskFlow i l'únic que no ha rebut cap peça nova des que es va construir completament en una sola lliçó, el mòdul 4. S'inclou aquí, íntegre, com a punt de partida més senzill abans dels altres dos:
// src/components/user-avatar.js
import { LitElement, html, css } from 'lit';
class UserAvatar extends LitElement {
// Mòdul 3: propietat reactiva simple.
static properties = {
nombre: { type: String },
};
// Mòdul 4 (04-03): variable CSS de theming amb valor per defecte a :host.
// Mòdul 4 (04-04): estilització de contingut distribuït amb ::slotted().
static styles = css`
:host {
display: inline-block;
--tamano-avatar: 2rem;
}
.avatar {
width: var(--tamano-avatar);
height: var(--tamano-avatar);
border-radius: 50%;
background-color: #cbd5e1;
color: #1f2933;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
font-weight: bold;
overflow: hidden;
}
::slotted(img) {
width: 100%;
height: 100%;
object-fit: cover;
}
`;
constructor() {
super();
this.nombre = '';
}
// Mòdul 4 (04-04): slot amb contingut de recanvi calculat a partir
// d'una propietat reactiva, mostrat únicament si ningú no distribueix res.
render() {
return html`
<div class="avatar" title="${this.nombre}">
<slot>${this.iniciales()}</slot>
</div>
`;
}
iniciales() {
if (!this.nombre) {
return '?';
}
return this.nombre
.split(' ')
.map((palabra) => palabra.charAt(0).toUpperCase())
.slice(0, 2)
.join('');
}
}
customElements.define('user-avatar', UserAvatar);Res d'aquest fitxer ha canviat des de la lliçó "Slots i Estilització de Contingut Distribuït" (04-04); s'inclou complet únicament perquè les tres lliçons d'aquest mòdul formin, juntes, el projecte enter sense que falti cap peça.
<task-card> complet: propietats, estat i controlador d'urgència
<task-card> complet: propietats, estat i controlador d'urgència<task-card>, el component més gran de TaskFlow, reuneix peces de set lliçons diferents. Aquest apartat cobreix la seva declaració de propietats, el seu conversor de data i el seu controlador reactiu:
// src/components/task-card.js
import { LitElement, html, css } from 'lit';
import { classMap } from 'lit/directives/class-map.js';
import { ContadorTiempoRestanteController } from '../controllers/contador-tiempo-restante-controller.js';
import { resaltarSiUrgente } from '../directives/resaltar-si-urgente.js';
import { estilosCompartidos } from '../styles/shared-styles.js';
import './user-avatar.js';
// Mòdul 3 (03-03): conversor personalitzat per a un tipus que no encaixa
// en els cinc tipus admesos de sèrie per Lit.
const conversorDeFecha = {
fromAttribute(valorDelAtributo) {
if (!valorDelAtributo) {
return null;
}
const fecha = new Date(valorDelAtributo);
return Number.isNaN(fecha.getTime()) ? null : fecha;
},
toAttribute(valorDeLaPropiedad) {
if (!valorDeLaPropiedad) {
return null;
}
return valorDeLaPropiedad.toISOString().split('T')[0];
},
};
class TaskCard extends LitElement {
// Mòdul 3 (03-01, 03-02, 03-03): propietats públiques, estat intern
// i conversor personalitzat, tot declarat en un únic lloc.
static properties = {
titulo: { type: String },
estado: { type: String },
prioridad: { type: Number },
urgente: { type: Boolean },
asignadoA: { type: String, attribute: 'asignado-a' },
asignadoImagen: { type: String, attribute: 'asignado-imagen' },
expandida: { state: true },
fechaLimite: { converter: conversorDeFecha, attribute: 'fecha-limite' },
};
constructor() {
super();
this.titulo = 'Tarea sin título';
this.estado = 'pendiente';
this.prioridad = 3;
this.urgente = false;
this.asignadoA = '';
this.asignadoImagen = '';
this.expandida = false;
this.fechaLimite = null;
// Mòdul 6 (06-03): controlador reactiu amb cicle de vida propi,
// registrat sobre aquest host des del seu propi constructor.
this._contadorTiempo = new ContadorTiempoRestanteController(this);
}
// ...continua a l'apartat següent amb la interacció i l'accessibilitat...
}Cal notar que asignadoA i asignadoImagen, introduïdes de passada a la lliçó 04-04 sense declarar-ne explícitament l'atribut, queden aquí amb un attribute en kebab-case (asignado-a, asignado-imagen), seguint la mateixa convenció aplicada a la resta de propietats compostes de <task-card> des del mòdul 3; és un d'aquells petits detalls que cap lliçó anterior va haver de resoldre perquè mai no es va mostrar la declaració completa de static properties amb totes dues propietats alhora.
<task-card> complet: interacció, esdeveniments i accessibilitat
<task-card> complet: interacció, esdeveniments i accessibilitatSobre la base de l'apartat anterior, <task-card> afegeix els mètodes que gestionen la interacció de l'usuari, l'esdeveniment que puja cap a <task-list>, i el suport d'accessibilitat de la lliçó 09-02:
// Mòdul 3 (03-02): alternar un estat intern amb un clic.
alternarExpandida() {
this.expandida = !this.expandida;
}
// Mòdul 9 (09-02): Enter i Espai han de disparar la mateixa acció que el clic.
_gestionarTeclaExpandir(event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.alternarExpandida();
}
}
// Mòdul 5 (05-02, 05-03): selector d'estat que emet un esdeveniment
// personalitzat cap amunt, sense tocar directament cap dada aliena.
gestionarCambioDeSelector(event) {
event.stopPropagation();
const nuevoEstado = event.target.value;
this.estado = nuevoEstado;
this.notificarCambioDeEstado(nuevoEstado);
}
notificarCambioDeEstado(nuevoEstado) {
this.dispatchEvent(
new CustomEvent('tarea-cambiada', {
detail: { nuevoEstado },
bubbles: true,
composed: true,
})
);
}
// Mòdul 4 (04-04): decideix si distribuir una imatge real o deixar que
// <user-avatar> calculi les seves pròpies inicials de recanvi.
renderAvatar() {
if (this.asignadoImagen) {
return html`
<user-avatar nombre="${this.asignadoA}">
<img src="${this.asignadoImagen}" alt="${this.asignadoA}" />
</user-avatar>
`;
}
return html`<user-avatar nombre="${this.asignadoA}"></user-avatar>`;
}
// Mòdul 7 (07-01): classMap substitueix les branques if que decidien la
// classe de la insígnia; un objecte a part decideix el text.
renderInsigniaEstado() {
const clases = {
insignia: true,
'insignia--pendiente': this.estado === 'pendiente',
'insignia--en-progreso': this.estado === 'en-progreso',
'insignia--hecha': this.estado === 'hecha',
};
const texto = {
pendiente: '○ Pendiente',
'en-progreso': '◐ En progreso',
hecha: '✓ Hecha',
}[this.estado];
return html`<span class="${classMap(clases)}">${texto}</span>`;
}
renderSelectorEstado() {
return html`
<select @change="${this.gestionarCambioDeSelector}" .value="${this.estado}">
<option value="pendiente">Pendiente</option>
<option value="en-progreso">En progreso</option>
<option value="hecha">Hecha</option>
</select>
`;
}
renderFechaLimite() {
if (!this.fechaLimite) {
return '';
}
return html`<p>Fecha límite: ${this.fechaLimite.toLocaleDateString('es-ES')}</p>`;
}Tot l'anterior reprodueix, sense cap canvi de comportament, el que ja s'ha explicat a les lliçons 03-02, 04-04, 05-02, 05-03, 07-01 i 09-02; l'única raó per tornar-ho a escriure aquí és que, fins ara, mai no havia convisut en un únic bloc de codi llegible d'un extrem a l'altre.
<task-card> complet: estils i render() final
<task-card> complet: estils i render() finalAmb tots els mètodes ja definits, render() els combina, juntament amb el rol, l'estat d'accessibilitat i la directiva resaltarSiUrgente sobre el propi <article>:
render() {
return html`
<article
role="button"
tabindex="0"
aria-expanded="${this.expandida}"
@click="${this.alternarExpandida}"
@keydown="${this._gestionarTeclaExpandir}"
${resaltarSiUrgente(this._contadorTiempo.cercaDeVencer)}
>
<div class="cabecera">
${this.renderAvatar()}
<h3>${this.titulo}</h3>
</div>
${this.renderInsigniaEstado()}
${this.renderSelectorEstado()}
<p>Prioridad: ${this.prioridad}</p>
${this.renderFechaLimite()}
${this.urgente && html`<p class="aviso">⚠ Urgente</p>`}
<p aria-live="polite">
${this._contadorTiempo.cercaDeVencer ? '⏰ Está a punto de vencer' : ''}
</p>
${this.expandida
? html`<div class="detalle" tabindex="-1"><p>Estado interno: la tarjeta está expandida.</p></div>`
: ''}
</article>
`;
}
// Mòdul 4 (04-02, 04-03): estils compartits més variables CSS de theming.
// Mòdul 7 (07-02): la directiva resaltarSiUrgente afegeix i treu la classe
// "resaltada" sobre l'<article>, però la seva animació mai no va arribar a
// definir-se a la lliçó que la va introduir; es tanca ara aquesta peça pendent.
static styles = [
estilosCompartidos,
css`
:host {
--color-pendiente: #94a3b8;
--color-en-progreso: #f59e0b;
--color-hecha: #22c55e;
--color-urgente: #dc2626;
}
article {
border: 1px solid #d0d5dd;
border-radius: 8px;
padding: 1rem;
margin-bottom: 0.75rem;
background-color: #ffffff;
}
.cabecera {
display: flex;
align-items: center;
gap: 0.5rem;
}
.insignia {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-size: 0.8rem;
margin-bottom: 0.5rem;
color: #ffffff;
}
.insignia--pendiente {
background-color: var(--color-pendiente);
}
.insignia--en-progreso {
background-color: var(--color-en-progreso);
}
.insignia--hecha {
background-color: var(--color-hecha);
}
.aviso {
color: var(--color-urgente);
font-weight: bold;
}
article.resaltada {
animation: destello 1.5s ease-out;
}
@keyframes destello {
0% {
box-shadow: 0 0 0 3px var(--color-urgente);
}
100% {
box-shadow: 0 0 0 0 transparent;
}
}
`,
];
}
customElements.define('task-card', TaskCard);La regla article.resaltada i el seu @keyframes destello són l'única peça d'aquesta lliçó que no apareixia, ni tan sols parcialment, en cap mòdul anterior: la lliçó "Directives Personalitzades" (07-02) va explicar amb detall la lògica de resaltarSiUrgente —quan afegeix i quan treu la classe resaltada— però, centrada en la mateixa directiva, no va arribar a definir quin aspecte visual havia de tenir aquesta classe. Una vora que apareix amb un box-shadow de color d'avís i es dissol en 1,5 segons, coincidint exactament amb el temps que la directiva manté la classe activa, tanca aquesta peça sense necessitat de cap concepte nou: és CSS convencional, aplicat sobre una classe que ja existia des del mòdul 7.
<task-filter>, complet
<task-filter>, complet<task-filter> reuneix dues lliçons: la seva construcció original amb @lit/context (07-04) i la seva revisió d'accessibilitat (09-02).
// src/components/task-filter.js
import { LitElement, html, css } from 'lit';
import { classMap } from 'lit/directives/class-map.js';
import { ContextConsumer } from '@lit/context';
import { filtroContext } from '../context/filtro-context.js';
class TaskFilter extends LitElement {
constructor() {
super();
// Mòdul 7 (07-04): consumidor del context de filtre, subscrit a
// qualsevol canvi futur publicat per <task-board>.
this._filtro = new ContextConsumer(this, { context: filtroContext, subscribe: true });
}
get valorActual() {
return this._filtro.value ?? { texto: '', estado: 'todas', actualizar: () => {} };
}
manejarTexto(evento) {
this.valorActual.actualizar({ texto: evento.target.value });
}
manejarEstado(estado) {
this.valorActual.actualizar({ estado });
}
// Mòdul 9 (09-02): etiquetes accessibles i estat comunicat amb ARIA,
// derivat sempre de la mateixa condició que ja decideix la classe visual.
render() {
const { texto, estado } = this.valorActual;
return html`
<div class="filtro" role="search">
<input
type="text"
aria-label="Buscar tarea por título"
placeholder="Buscar tarea…"
.value="${texto}"
@input="${this.manejarTexto}"
/>
<div class="filtro__botones" role="group" aria-label="Filtrar por estado">
${['todas', 'pendiente', 'hecha'].map(
(opcion) => html`
<button
class="${classMap({ activo: estado === opcion })}"
aria-pressed="${estado === opcion}"
@click="${() => this.manejarEstado(opcion)}"
>
${{ todas: 'Todas', pendiente: 'Pendientes', hecha: 'Hechas' }[opcion]}
</button>
`
)}
</div>
</div>
`;
}
static styles = css`
.filtro {
display: flex;
gap: 0.75rem;
align-items: center;
margin-bottom: 1rem;
}
.filtro__botones {
display: flex;
gap: 0.5rem;
}
.filtro__botones button.activo {
font-weight: bold;
border-bottom: 2px solid currentColor;
}
`;
}
customElements.define('task-filter', TaskFilter);Aquest fitxer és, paraula per paraula, la unió de les versions de 07-04 i 09-02: no cal cap reconciliació addicional, perquè la lliçó 09-02 ja es va escriure sobre el <task-filter> que existia en aquell moment del curs, sense contradir cap de les seves peces anteriors.
- Quin mòdul va aportar cada peça: taula resum
| Peça de codi | Mòdul i lliçó d'origen |
|---|---|
static properties de <task-card> |
03-01, 03-02, 03-03 |
| Conversor de data | 03-03 |
renderAvatar() i <user-avatar> complet |
04-03, 04-04 |
Selector d'estat i tarea-cambiada |
05-02, 05-03 |
ContadorTiempoRestanteController al constructor |
06-03 |
classMap a renderInsigniaEstado() |
07-01 |
resaltarSiUrgente sobre l'<article> |
07-02 |
ContextConsumer a <task-filter> |
07-04 |
role, aria-expanded, aria-live, aria-pressed, teclat |
09-02 |
Animació CSS de .resaltada |
Tancat en aquesta lliçó |
- Conclusió cap a la gestió d'estat
Amb <user-avatar>, <task-card> i <task-filter> ja consolidats en els seus fitxers finals, TaskFlow té resolts tots els components que mostren dades directament a l'usuari. Falten, encara, els dos que els orquestren: <task-board>, propietari de l'array tareas i del context que <task-filter> acaba de consumir en aquest apartat, i <task-list>, que decideix quines tasques compleixen aquest filtre abans de repartir-les entre les targetes. Aquesta és exactament la tasca de la lliçó següent.
Errors Comuns i Consells
- Copiar i enganxar fragments de lliçons diferents sense comprovar que les propietats declarades coincideixen: com s'ha vist a l'apartat 3,
asignadoAiasignadoImagenmai no havien aparegut juntes en la mateixa declaració destatic propertiesabans d'aquesta lliçó; en consolidar peces de diverses lliçons, convé revisar que cap propietat usada en un mètode quedi sense declarar en el conjunt final. - Oblidar que una directiva i el seu CSS són peces separades:
resaltarSiUrgente(JavaScript) i.resaltada(CSS) es van explicar, deliberadament, en moments diferents del curs; com s'ha vist a l'apartat 5, totes dues peces són necessàries perquè l'efecte visual s'apreciï, i cap de les dues per separat no n'hi ha prou. - Duplicar lògica entre
<task-card>i<task-filter>: encara que tots dos utilitzenclassMap, cadascun l'aplica a un problema diferent (una insígnia d'estat davant d'un botó d'alternança); no cal, ni convé, extreure una funció compartida només perquè tots dos utilitzen la mateixa directiva. - Pensar que consolidar significa reescriure: com s'ha insistit a l'apartat 1, el comportament de cada peça no canvia; consolidar és unir i completar buits concrets, no redissenyar decisions ja preses i ja provades en mòduls anteriors.
Exercicis
- Afegeix a
<task-card>una variable CSS--color-borde-tarjeta(seguint el patró de la lliçó 04-03, exercici 1) i substitueix el valor fix delborderd'articlepervar(--color-borde-tarjeta, #d0d5dd), amb el mateix valor d'abans com a valor de reserva. - Explica, basant-te en l'apartat 5, per què la durada de l'animació
destello(1,5 segons) ha de coincidir amb elduracionMsper defecte deresaltarSiUrgenteassenyalat a la lliçó 07-02, i què passaria visualment si els dos valors no coincidissin. - Un company d'equip, revisant
<task-filter>, proposa extreurerole="search"irole="group"a una funció auxiliar compartida amb<task-card>, ja que tots dos utilitzen atributs ARIA. Explica per què aquesta extracció no aportaria cap avantatge real, recolzant-te en l'apartat 7 de la taula resum.
Solucions
static styles = [
estilosCompartidos,
css`
:host {
--color-pendiente: #94a3b8;
--color-en-progreso: #f59e0b;
--color-hecha: #22c55e;
--color-urgente: #dc2626;
--color-borde-tarjeta: #d0d5dd;
}
article {
border: 1px solid var(--color-borde-tarjeta);
/* ...resta sense canvis... */
}
`,
];- La directiva
resaltarSiUrgente, tal com va quedar a la lliçó 07-02, programa unsetTimeoutque retira la classeresaltadadesprés d'un nombre fix de mil·lisegons (1500 per defecte); si l'animació CSS durés, per exemple, 3 segons mentre la classe només roman 1,5 segons, el navegador interrompria l'animació a mig camí en l'instant exacte en quèclassList.remove('resaltada')s'executa, produint un salt visual brusc en lloc de la dissolució gradual que@keyframes destellopretén aconseguir. Tots dos valors —el de JavaScript i el de CSS— han de coincidir perquè elsetTimeoutretiri la classe just quan l'animació ha acabat de reproduir-se completament. role="search"irole="group"a<task-filter>descriuen la naturalesa semàntica dels seus propis controls (una zona de cerca, un grup de botons d'alternança);<task-card>no té cap control equivalent a un grup de botons ni a una zona de cerca, per la qual cosa no existeix cap lògica comuna real per extreure, més enllà que tots dos, per casualitat, utilitzen atributs que comencen perrole. Com mostra la taula de l'apartat 7, cada atribut d'accessibilitat d'aquesta lliçó resol un problema concret d'un component concret; forçar una funció compartida només per la coincidència superficial d'usar ARIA introduiria una indirecció sense cap benefici de reutilització real.
Conclusió
Aquesta lliçó ha unit, en tres fitxers complets i llegibles de principi a fi, totes les peces que el curs va anar afegint per separat a <user-avatar>, <task-card> i <task-filter> entre els mòduls 3 i 9, i ha tancat una peça que quedava pendent des del mòdul 7: l'animació CSS de la classe resaltada. No ha aparegut cap concepte nou de Lit; tot el treball ha consistit a ensamblar correctament el que ja s'havia après.
Queden, tanmateix, dues peces més per consolidar: <task-board> i <task-list>, juntament amb el context de filtre, el controlador reactiu i el mixin d'estat de càrrega que tots dos utilitzen. Aquesta és la tasca de la lliçó següent, "Gestió d'Estat i Comunicació entre Components", que a més completa una peça que va quedar pendent des del mòdul 7: com mantenir <task-list> correctament sincronitzada després de la càrrega inicial simulada amb until.
Curs de Lit
Mòdul 1: Introducció a Lit i Web Components
- Què són els Web Components i per què Lit?
- Configuració de l'Entorn de Desenvolupament
- El Teu Primer Component Lit
- Anatomia d'un Component Lit
Mòdul 2: Plantilles Reactives i Renderitzat
- El Motor de Plantilles de Lit
- Expressions i Interpolació en Plantilles
- Renderitzat Condicional
- Renderitzat de Llistes
- El Cicle de Renderitzat
Mòdul 3: Propietats i Estat Reactiu
- Propietats Reactives
- Estat Intern amb @state
- Tipus de Propietats i Conversors Personalitzats
- Atributs vs Propietats i Reflexió
Mòdul 4: Estils en Components Lit
- CSS Encapsulat amb Shadow DOM
- Estils Compartits entre Components
- Variables CSS Personalitzades i Theming
- Slots i Estilitzat de Contingut Distribuït
Mòdul 5: Esdeveniments i Comunicació entre Components
- Gestió d'Esdeveniments DOM en Plantilles
- Esdeveniments Personalitzats: Comunicació de Fill a Pare
- Comunicació de Pare a Fill amb Propietats
- Patrons de Comunicació entre Components Germans
Mòdul 6: Cicle de Vida i Comportament Avançat
- Callbacks del Cicle de Vida
- Hooks Reactius: willUpdate, updated i firstUpdated
- Controladors Reactius
- Mixins i Composició de Comportament
Mòdul 7: Directives i Funcionalitats Avançades de Plantilles
- Directives Incorporades: classMap, styleMap i ifDefined
- Directives Personalitzades
- Renderitzat Asíncron amb until
- Context Compartit amb @lit/context
Mòdul 8: Integració, Interoperabilitat i Desplegament
- Utilitzar Components Lit en HTML Pla
- Integrar Lit amb React, Vue i Angular
- Renderitzat al Servidor amb @lit-labs/ssr
- Empaquetatge, Publicació i TypeScript
Mòdul 9: Proves i Bones Pràctiques
- Proves Unitàries amb Web Test Runner
- Accessibilitat en Web Components
- Rendiment i Optimització
- Patrons i Antipatrons Comuns
