With the map from the previous lesson already drawn, this lesson starts filling it in for real: the complete code for <user-avatar>, <task-card>, and <task-filter>, as they stand at the end of the course, each in a single coherent file instead of spread across the lessons that built it piece by piece. No code fragment in this lesson introduces a new technique; each block carries a comment pointing to the lesson of the course where it was explained, and the only genuinely new work is closing a couple of pieces that were mentioned without being fully resolved, such as the CSS animation itself for the resaltada class that the resaltarSiUrgente directive adds and removes, but whose visual appearance was never actually defined.
Contents
- What "consolidating" means in this module
<user-avatar>, in full<task-card>in full: properties, state, and urgency controller<task-card>in full: interaction, events, and accessibility<task-card>in full: styles and the finalrender()<task-filter>, in full- Which module contributed each piece: summary table
- Conclusion towards state management
- What "consolidating" means in this module
Each of the three components in this lesson appeared, throughout the course, in successive fragments: one lesson created it with the bare minimum, and later lessons, focused on a different technique, kept adding pieces to it without showing the complete file again each time (so as not to repeat, in every lesson, code already explained in the previous one). The result is correct, but it is spread across nine different modules. Consolidating means, here, two things at once: joining all those pieces into a single file per component, and taking advantage of the overall view to complete the few details that were left only sketched out, such as the resaltada animation from section 5.
<user-avatar>, in full
<user-avatar>, in full<user-avatar> is TaskFlow's smallest component and the only one that has not received any new piece since it was built in full in a single lesson, module 4. It is included here, in full, as the simplest starting point before the other two:
// src/components/user-avatar.js
import { LitElement, html, css } from 'lit';
class UserAvatar extends LitElement {
// Module 3: simple reactive property.
static properties = {
nombre: { type: String },
};
// Module 4 (04-03): theming CSS variable with a default value on :host.
// Module 4 (04-04): styling distributed content with ::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 = '';
}
// Module 4 (04-04): slot with fallback content computed from
// a reactive property, shown only if nobody distributes anything.
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);Nothing in this file has changed since the lesson "Slots and Styling Distributed Content" (04-04); it is included in full only so that the three lessons of this module together form the entire project without any piece missing.
<task-card> in full: properties, state, and urgency controller
<task-card> in full: properties, state, and urgency controller<task-card>, TaskFlow's largest component, brings together pieces from seven different lessons. This section covers its property declaration, its date converter, and its reactive controller:
// 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';
// Module 3 (03-03): custom converter for a type that does not fit
// into the five types supported by Lit out of the box.
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 {
// Module 3 (03-01, 03-02, 03-03): public properties, internal state,
// and custom converter, all declared in a single place.
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;
// Module 6 (06-03): reactive controller with its own lifecycle,
// registered on this host from its own constructor.
this._contadorTiempo = new ContadorTiempoRestanteController(this);
}
// ...continues in the next section with interaction and accessibility...
}Note that asignadoA and asignadoImagen, introduced in passing in lesson 04-04 without explicitly declaring their attribute, are left here with an attribute in kebab-case (asignado-a, asignado-imagen), following the same convention applied to the rest of <task-card>'s compound properties since module 3; it is one of those small details that no earlier lesson had to resolve because the full static properties declaration with both properties at once was never shown.
<task-card> in full: interaction, events, and accessibility
<task-card> in full: interaction, events, and accessibilityBuilding on the previous section, <task-card> adds the methods that handle user interaction, the event that bubbles up to <task-list>, and the accessibility support from lesson 09-02:
// Module 3 (03-02): toggling an internal state with a click.
alternarExpandida() {
this.expandida = !this.expandida;
}
// Module 9 (09-02): Enter and Space must trigger the same action as the click.
_gestionarTeclaExpandir(event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.alternarExpandida();
}
}
// Module 5 (05-02, 05-03): status selector that emits a custom event
// upward, without directly touching any data that is not its own.
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,
})
);
}
// Module 4 (04-04): decides whether to distribute a real image or let
// <user-avatar> compute its own fallback initials.
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>`;
}
// Module 7 (07-01): classMap replaces the if branches that used to decide
// the badge's class; a separate object decides the 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>`;
}All of the above reproduces, with no change in behavior, what was already explained in lessons 03-02, 04-04, 05-02, 05-03, 07-01, and 09-02; the only reason to write it out again here is that, until now, it had never coexisted in a single block of code readable from one end to the other.
<task-card> in full: styles and the final render()
<task-card> in full: styles and the final render()With all the methods already defined, render() combines them, together with the role, the accessibility state, and the resaltarSiUrgente directive on the <article> itself:
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>
`;
}
// Module 4 (04-02, 04-03): shared styles plus theming CSS variables.
// Module 7 (07-02): the resaltarSiUrgente directive adds and removes the
// "resaltada" class on the <article>, but its animation was never actually
// defined in the lesson that introduced it; that pending piece is closed now.
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);The article.resaltada rule and its @keyframes destello are the only piece in this lesson that did not appear, not even partially, in any earlier module: the lesson "Custom Directives" (07-02) explained in detail the logic of resaltarSiUrgente —when it adds and when it removes the resaltada class— but, focused on the directive itself, never got around to defining what visual appearance that class should have. A border that appears with a warning-colored box-shadow and fades away over 1.5 seconds, matching exactly the time the directive keeps the class active, closes that piece without needing any new concept: it is conventional CSS, applied to a class that already existed since module 7.
<task-filter>, in full
<task-filter>, in full<task-filter> brings together two lessons: its original construction with @lit/context (07-04) and its accessibility review (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();
// Module 7 (07-04): filter context consumer, subscribed to
// any future change published by <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 });
}
// Module 9 (09-02): accessible labels and state communicated with ARIA,
// always derived from the same condition that already decides the visual class.
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);This file is, word for word, the union of the 07-04 and 09-02 versions: no further reconciliation is needed, because lesson 09-02 was already written against the <task-filter> that existed at that point in the course, without contradicting any of its earlier pieces.
- Which module contributed each piece: summary table
| Code piece | Module and origin lesson |
|---|---|
static properties of <task-card> |
03-01, 03-02, 03-03 |
| Date converter | 03-03 |
renderAvatar() and <user-avatar> in full |
04-03, 04-04 |
Status selector and tarea-cambiada |
05-02, 05-03 |
ContadorTiempoRestanteController in the constructor |
06-03 |
classMap in renderInsigniaEstado() |
07-01 |
resaltarSiUrgente on the <article> |
07-02 |
ContextConsumer in <task-filter> |
07-04 |
role, aria-expanded, aria-live, aria-pressed, keyboard |
09-02 |
CSS animation for .resaltada |
Closed in this lesson |
- Conclusion towards state management
With <user-avatar>, <task-card>, and <task-filter> now consolidated in their final files, TaskFlow has all the components that display data directly to the user resolved. Still missing are the two that orchestrate them: <task-board>, owner of the tareas array and of the context that <task-filter> has just consumed in this section, and <task-list>, which decides which tasks match that filter before distributing them among the cards. That is exactly the task of the next lesson.
Common Mistakes and Tips
- Copying and pasting fragments from different lessons without checking that the declared properties match: as seen in section 3,
asignadoAandasignadoImagenhad never appeared together in the samestatic propertiesdeclaration before this lesson; when consolidating pieces from several lessons, it is worth checking that no property used in a method is left undeclared in the final set. - Forgetting that a directive and its CSS are separate pieces:
resaltarSiUrgente(JavaScript) and.resaltada(CSS) were deliberately explained at different points in the course; as seen in section 5, both pieces are needed for the visual effect to show, and neither one by itself is enough. - Duplicating logic between
<task-card>and<task-filter>: although both useclassMap, each applies it to a different problem (a status badge versus a toggle button); there is no need, nor is it advisable, to extract a shared function just because both use the same directive. - Thinking that consolidating means rewriting: as stressed in section 1, the behavior of each piece does not change; consolidating means joining pieces together and filling in specific gaps, not redesigning decisions already made and already tested in earlier modules.
Exercises
- Add to
<task-card>a CSS variable--color-borde-tarjeta(following the pattern from lesson 04-03, exercise 1) and replace the fixed value ofarticle'sborderwithvar(--color-borde-tarjeta, #d0d5dd), with the same previous value as a fallback. - Explain, based on section 5, why the duration of the
destelloanimation (1.5 seconds) must match the defaultduracionMsofresaltarSiUrgentenoted in lesson 07-02, and what would happen visually if the two values did not match. - A teammate, reviewing
<task-filter>, proposes extractingrole="search"androle="group"into a helper function shared with<task-card>, since both use ARIA attributes. Explain why that extraction would not bring any real advantage, drawing on the summary table in section 7.
Solutions
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);
/* ...rest unchanged... */
}
`,
];- The
resaltarSiUrgentedirective, as it stood in lesson 07-02, schedules asetTimeoutthat removes theresaltadaclass after a fixed number of milliseconds (1500 by default); if the CSS animation lasted, say, 3 seconds while the class only stays for 1.5 seconds, the browser would interrupt the animation halfway through, at the exact instantclassList.remove('resaltada')runs, producing an abrupt visual jump instead of the gradual fade that@keyframes destellois meant to achieve. Both values —the JavaScript one and the CSS one— must match so thatsetTimeoutremoves the class right when the animation has finished playing all the way through. role="search"androle="group"on<task-filter>describe the semantic nature of its own controls (a search area, a group of toggle buttons);<task-card>has no control equivalent to a button group or a search area, so there is no real shared logic to extract, beyond the fact that both happen to use attributes starting withrole. As the table in section 7 shows, each accessibility attribute in this lesson solves a specific problem for a specific component; forcing a shared function just because of the superficial coincidence of using ARIA would introduce an indirection with no real reuse benefit.
Conclusion
This lesson has joined, into three complete files readable from start to finish, all the pieces that the course kept adding separately to <user-avatar>, <task-card>, and <task-filter> between modules 3 and 9, and has closed a piece that had been pending since module 7: the CSS animation for the resaltada class. No new Lit concept has appeared; all the work has consisted of correctly assembling what was already learned.
There remain, however, two more pieces to consolidate: <task-board> and <task-list>, together with the filter context, the reactive controller, and the loading-state mixin that both use. That is the task of the next lesson, "State Management and Communication Between Components", which also completes a piece that had been pending since module 7: how to keep <task-list> correctly synchronized after the initial load simulated with until.
Lit Course
Module 1: Introduction to Lit and Web Components
- What are Web Components and why Lit?
- Setting Up the Development Environment
- Your First Lit Component
- Anatomy of a Lit Component
Module 2: Reactive Templates and Rendering
- Lit's Template Engine
- Expressions and Interpolation in Templates
- Conditional Rendering
- List Rendering
- The Rendering Cycle
Module 3: Reactive Properties and State
- Reactive Properties
- Internal State with @state
- Types of Properties and Custom Converters
- Attributes vs Properties and Reflection
Module 4: Styling Lit Components
- Encapsulated CSS with Shadow DOM
- Shared Styles Between Components
- Custom CSS Properties and Theming
- Slots and Styling Distributed Content
Module 5: Events and Component Communication
- Handling DOM Events in Templates
- Custom Events: Communication from Child to Parent
- Communication from Parent to Child with Properties
- Communication Patterns Between Sibling Components
Module 6: Lifecycle and Advanced Behavior
- Lifecycle Callbacks
- Reactive Hooks: willUpdate, updated, and firstUpdated
- Reactive Controllers
- Mixins and Composing Behavior
Module 7: Directives and Advanced Template Features
- Built-in Directives: classMap, styleMap and ifDefined
- Custom Directives
- Asynchronous Rendering with until
- Shared Context with @lit/context
Module 8: Integration, Interoperability and Deployment
- Using Lit Components in Plain HTML
- Integrating Lit with React, Vue, and Angular
- Server-Side Rendering with @lit-labs/ssr
- Bundling, Publishing, and TypeScript
Module 9: Testing and Best Practices
- Unit Tests with Web Test Runner
- Accessibility in Web Components
- Performance and Optimization
- Common Patterns and Anti-patterns
