Con el mapa de la lección anterior ya trazado, esta lección empieza a rellenarlo de verdad: el código completo de <user-avatar>, <task-card> y <task-filter>, tal como quedan al final del curso, cada uno en un único fichero coherente en lugar de repartido entre las lecciones que lo fueron construyendo por partes. Ningún fragmento de código de esta lección introduce una técnica nueva; cada bloque lleva un comentario que señala en qué lección del curso se explicó, y el único trabajo genuinamente nuevo es cerrar un par de piezas que quedaron mencionadas sin resolver del todo, como la propia animación CSS de la clase resaltada que la directiva resaltarSiUrgente añade y quita, pero cuyo aspecto visual nunca llegó a definirse.

Contenido

  1. Qué significa "consolidar" en este módulo
  2. <user-avatar>, completo
  3. <task-card> completo: propiedades, estado y controlador de urgencia
  4. <task-card> completo: interacción, eventos y accesibilidad
  5. <task-card> completo: estilos y render() final
  6. <task-filter>, completo
  7. Qué módulo aportó cada pieza: tabla resumen
  8. Conclusión hacia la gestión de estado

  1. Qué significa "consolidar" en este módulo

Cada uno de los tres componentes de esta lección apareció, a lo largo del curso, en fragmentos sucesivos: una lección lo creaba con lo mínimo indispensable, y lecciones posteriores, centradas en una técnica distinta, le iban añadiendo piezas sin volver a mostrar el fichero completo cada vez (para no repetir, en cada lección, código ya explicado en la anterior). El resultado es correcto, pero está distribuido entre nueve módulos distintos. Consolidar significa, aquí, dos cosas a la vez: unir todas esas piezas en un único fichero por componente, y aprovechar la vista de conjunto para completar los pocos detalles que quedaron solo esbozados, como la animación de resaltada del apartado 5.

  1. <user-avatar>, completo

<user-avatar> es el componente más pequeño de TaskFlow y el único que no ha recibido ninguna pieza nueva desde que se construyó por completo en una sola lección, el módulo 4. Se incluye aquí, íntegro, como punto de partida más sencillo antes de los otros dos:

// src/components/user-avatar.js
import { LitElement, html, css } from 'lit';

class UserAvatar extends LitElement {
  // Módulo 3: propiedad reactiva simple.
  static properties = {
    nombre: { type: String },
  };

  // Módulo 4 (04-03): variable CSS de theming con valor por defecto en :host.
  // Módulo 4 (04-04): estilizado de contenido distribuido con ::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ódulo 4 (04-04): slot con contenido de repuesto calculado a partir
  // de una propiedad reactiva, mostrado únicamente si nadie distribuye nada.
  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);

Nada de este fichero ha cambiado desde la lección "Slots y Estilizado de Contenido Distribuido" (04-04); se incluye completo únicamente para que las tres lecciones de este módulo formen, juntas, el proyecto entero sin que falte ninguna pieza.

  1. <task-card> completo: propiedades, estado y controlador de urgencia

<task-card>, el componente más grande de TaskFlow, reúne piezas de siete lecciones distintas. Este apartado cubre su declaración de propiedades, su conversor de fecha y su controlador reactivo:

// 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ódulo 3 (03-03): conversor personalizado para un tipo que no encaja
// en los cinco tipos soportados de serie por 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ódulo 3 (03-01, 03-02, 03-03): propiedades públicas, estado interno
  // y conversor personalizado, todo declarado en un único lugar.
  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ódulo 6 (06-03): controlador reactivo con ciclo de vida propio,
    // registrado sobre este host desde su propio constructor.
    this._contadorTiempo = new ContadorTiempoRestanteController(this);
  }

  // ...continúa en el apartado siguiente con la interacción y la accesibilidad...
}

Nótese que asignadoA y asignadoImagen, introducidas de pasada en la lección 04-04 sin declarar explícitamente su atributo, quedan aquí con un attribute en kebab-case (asignado-a, asignado-imagen), siguiendo la misma convención aplicada al resto de propiedades compuestas de <task-card> desde el módulo 3; es uno de esos pequeños detalles que ninguna lección anterior tuvo que resolver porque nunca se mostró la declaración completa de static properties con ambas propiedades a la vez.

  1. <task-card> completo: interacción, eventos y accesibilidad

Sobre la base del apartado anterior, <task-card> añade los métodos que gestionan la interacción del usuario, el evento que sube hacia <task-list>, y el soporte de accesibilidad de la lección 09-02:

  // Módulo 3 (03-02): alternar un estado interno con un clic.
  alternarExpandida() {
    this.expandida = !this.expandida;
  }

  // Módulo 9 (09-02): Enter y Espacio deben disparar la misma acción que el clic.
  _gestionarTeclaExpandir(event) {
    if (event.key === 'Enter' || event.key === ' ') {
      event.preventDefault();
      this.alternarExpandida();
    }
  }

  // Módulo 5 (05-02, 05-03): selector de estado que emite un evento
  // personalizado hacia arriba, sin tocar directamente ningún dato ajeno.
  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ódulo 4 (04-04): decide si distribuir una imagen real o dejar que
  // <user-avatar> calcule sus propias iniciales de repuesto.
  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ódulo 7 (07-01): classMap sustituye a las ramas if que decidían la
  // clase de la insignia; un objeto aparte decide el texto.
  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>`;
  }

Todo lo anterior reproduce, sin ningún cambio de comportamiento, lo ya explicado en las lecciones 03-02, 04-04, 05-02, 05-03, 07-01 y 09-02; la única razón para volver a escribirlo aquí es que, hasta ahora, nunca había convivido en un único bloque de código legible de un extremo a otro.

  1. <task-card> completo: estilos y render() final

Con todos los métodos ya definidos, render() los combina, junto con el rol, el estado de accesibilidad y la directiva resaltarSiUrgente sobre el propio <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ódulo 4 (04-02, 04-03): estilos compartidos más variables CSS de theming.
  // Módulo 7 (07-02): la directiva resaltarSiUrgente añade y quita la clase
  // "resaltada" sobre el <article>, pero su animación nunca llegó a definirse
  // en la lección que la introdujo; se cierra ahora esa pieza pendiente.
  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 y su @keyframes destello son la única pieza de esta lección que no aparecía, ni siquiera parcialmente, en ningún módulo anterior: la lección "Directivas Personalizadas" (07-02) explicó con detalle la lógica de resaltarSiUrgente —cuándo añade y cuándo quita la clase resaltada— pero, centrada en la propia directiva, no llegó a definir qué aspecto visual debía tener esa clase. Un borde que aparece con un box-shadow de color de aviso y se disuelve en 1,5 segundos, coincidiendo exactamente con el tiempo que la directiva mantiene la clase activa, cierra esa pieza sin necesidad de ningún concepto nuevo: es CSS convencional, aplicado sobre una clase que ya existía desde el módulo 7.

  1. <task-filter>, completo

<task-filter> reúne dos lecciones: su construcción original con @lit/context (07-04) y su revisión de accesibilidad (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ódulo 7 (07-04): consumidor del contexto de filtro, suscrito a
    // cualquier cambio futuro publicado por <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ódulo 9 (09-02): etiquetas accesibles y estado comunicado con ARIA,
  // derivado siempre de la misma condición que ya decide la clase 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);

Este fichero es, palabra por palabra, la unión de las versiones de 07-04 y 09-02: no hace falta ninguna reconciliación adicional, porque la lección 09-02 ya se escribió sobre el <task-filter> que existía en ese momento del curso, sin contradecir ninguna de sus piezas anteriores.

  1. Qué módulo aportó cada pieza: tabla resumen

Pieza de código Módulo y lección de origen
static properties de <task-card> 03-01, 03-02, 03-03
Conversor de fecha 03-03
renderAvatar() y <user-avatar> completo 04-03, 04-04
Selector de estado y tarea-cambiada 05-02, 05-03
ContadorTiempoRestanteController en el constructor 06-03
classMap en renderInsigniaEstado() 07-01
resaltarSiUrgente sobre el <article> 07-02
ContextConsumer en <task-filter> 07-04
role, aria-expanded, aria-live, aria-pressed, teclado 09-02
Animación CSS de .resaltada Cerrado en esta lección

  1. Conclusión hacia la gestión de estado

Con <user-avatar>, <task-card> y <task-filter> ya consolidados en sus ficheros finales, TaskFlow tiene resueltos todos los componentes que muestran datos directamente al usuario. Faltan, todavía, los dos que los orquestan: <task-board>, dueño del array tareas y del contexto que <task-filter> acaba de consumir en este apartado, y <task-list>, que decide qué tareas cumplen ese filtro antes de repartirlas entre las tarjetas. Esa es exactamente la tarea de la siguiente lección.

Errores Comunes y Consejos

  • Copiar y pegar fragmentos de lecciones distintas sin comprobar que las propiedades declaradas coinciden: como se ha visto en el apartado 3, asignadoA y asignadoImagen nunca habían aparecido juntas en la misma declaración de static properties antes de esta lección; al consolidar piezas de varias lecciones, conviene revisar que ninguna propiedad usada en un método quede sin declarar en el conjunto final.
  • Olvidar que una directiva y su CSS son piezas separadas: resaltarSiUrgente (JavaScript) y .resaltada (CSS) se explicaron, deliberadamente, en momentos distintos del curso; como se ha visto en el apartado 5, ambas piezas son necesarias para que el efecto visual se aprecie, y ninguna de las dos por separado basta.
  • Duplicar lógica entre <task-card> y <task-filter>: aunque ambos usan classMap, cada uno lo aplica a un problema distinto (una insignia de estado frente a un botón de alternancia); no hace falta, ni conviene, extraer una función compartida solo porque ambos usan la misma directiva.
  • Pensar que consolidar significa reescribir: como se ha insistido en el apartado 1, el comportamiento de cada pieza no cambia; consolidar es unir y completar huecos concretos, no rediseñar decisiones ya tomadas y ya probadas en módulos anteriores.

Ejercicios

  1. Añade a <task-card> una variable CSS --color-borde-tarjeta (siguiendo el patrón de la lección 04-03, ejercicio 1) y sustituye el valor fijo del border de article por var(--color-borde-tarjeta, #d0d5dd), con el mismo valor de antes como respaldo.
  2. Explica, basándote en el apartado 5, por qué la duración de la animación destello (1,5 segundos) debe coincidir con el duracionMs por defecto de resaltarSiUrgente señalado en la lección 07-02, y qué pasaría visualmente si ambos valores no coincidieran.
  3. Un compañero de equipo, revisando <task-filter>, propone extraer role="search" y role="group" a una función auxiliar compartida con <task-card>, ya que ambos usan atributos ARIA. Explica por qué esa extracción no aportaría ninguna ventaja real, apoyándote en el apartado 7 de la tabla resumen.

Soluciones

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);
      /* ...resto sin cambios... */
    }
  `,
];
  1. La directiva resaltarSiUrgente, tal como quedó en la lección 07-02, programa un setTimeout que retira la clase resaltada tras un número fijo de milisegundos (1500 por defecto); si la animación CSS durase, por ejemplo, 3 segundos mientras la clase solo permanece 1,5 segundos, el navegador interrumpiría la animación a mitad de camino en el instante exacto en que classList.remove('resaltada') se ejecuta, produciendo un salto visual brusco en lugar de la disolución gradual que @keyframes destello pretende lograr. Ambos valores —el de JavaScript y el de CSS— deben coincidir para que el setTimeout retire la clase justo cuando la animación ha terminado de reproducirse por completo.
  2. role="search" y role="group" en <task-filter> describen la naturaleza semántica de sus propios controles (una zona de búsqueda, un grupo de botones de alternancia); <task-card> no tiene ningún control equivalente a un grupo de botones ni a una zona de búsqueda, así que no existe ninguna lógica común real que extraer, más allá de que ambos, por casualidad, usan atributos que empiezan por role. Como muestra la tabla del apartado 7, cada atributo de accesibilidad de esta lección resuelve un problema concreto de un componente concreto; forzar una función compartida solo por la coincidencia superficial de usar ARIA introduciría una indirección sin ningún beneficio de reutilización real.

Conclusión

Esta lección ha unido, en tres ficheros completos y legibles de principio a fin, todas las piezas que el curso fue añadiendo por separado a <user-avatar>, <task-card> y <task-filter> entre los módulos 3 y 9, y ha cerrado una pieza que quedaba pendiente desde el módulo 7: la animación CSS de la clase resaltada. Ningún concepto nuevo de Lit ha aparecido; todo el trabajo ha consistido en ensamblar correctamente lo ya aprendido.

Quedan, sin embargo, dos piezas más por consolidar: <task-board> y <task-list>, junto con el contexto de filtro, el controlador reactivo y el mixin de estado de carga que ambos usan. Esa es la tarea de la siguiente lección, "Gestión de Estado y Comunicación entre Componentes", que además completa una pieza que quedó pendiente desde el módulo 7: cómo mantener <task-list> correctamente sincronizada después de la carga inicial simulada con until.

Curso de Lit

Módulo 1: Introducción a Lit y Web Components

Módulo 2: Plantillas Reactivas y Renderizado

Módulo 3: Propiedades y Estado Reactivo

Módulo 4: Estilos en Componentes Lit

Módulo 5: Eventos y Comunicación entre Componentes

Módulo 6: Ciclo de Vida y Comportamiento Avanzado

Módulo 7: Directivas y Funcionalidades Avanzadas de Plantillas

Módulo 8: Integración, Interoperabilidad y Despliegue

Módulo 9: Pruebas y Buenas Prácticas

Módulo 10: Proyecto: Construyendo TaskFlow

© Copyright 2026. Todos los derechos reservados