El andamiaje está montado: el proyecto arranca, el linter no protesta y json-server responde. Pero no hay nada que mirar. Esta lección levanta CicloUrbano entero por dentro: el sistema de diseño, el marco de la aplicación y las ocho pantallas, todas navegables y con aspecto definitivo.

Y lo hace con una restricción deliberada: con datos estáticos, sin tocar la red. La razón no es pedagógica, es de método. Una interfaz que ya se puede recorrer se valida en veinte minutos con quien va a usarla, y los cambios que salgan de esa revisión —que siempre salen— cuestan aquí una fracción de lo que costarían con hooks de consulta, mutaciones e invalidaciones enganchados encima. Separar «cómo se ve» de «de dónde vienen los datos» es lo que hace que 11-03 sea una lección de conexión y no una de reconstrucción.

Hay una segunda idea que gobierna todo lo que viene: los estados vacío, de carga y de error se diseñan ahora, no cuando aparezcan. La mayoría de las interfaces feas de este mundo son interfaces que solo se diseñaron para el caso feliz. Aquí, cada pantalla se escribe con sus cuatro estados desde el primer momento, aunque de momento tres de ellos se activen con una variable a mano.

Contenido

  1. Qué se construye aquí y qué se deja para 11-03
  2. El sistema de diseño: index.css
  3. Tema claro y oscuro con data-tema
  4. Los componentes base y su contrato de props
  5. Diseno: el marco de la aplicación
  6. Navegación con NavLink y migas de pan
  7. Accesibilidad estructural: puntos de referencia y salto al contenido
  8. Los datos de ejemplo mientras no hay red
  9. Los cuatro estados de cada pantalla
  10. Pantalla 1: el catálogo
  11. Pantalla 2: la ficha de una bicicleta
  12. Pantallas 3 y 4: estaciones y detalle con pestañas
  13. Pantalla 5: mis reservas
  14. Pantalla 6: nueva reserva
  15. Pantallas 7 y 8: acceso y taller
  16. Las pantallas de error
  17. Diseño adaptable con CSS Modules
  18. La lista de comprobación de accesibilidad por pantalla
  19. Recorrido de revisión de la interfaz terminada

  1. Qué se construye aquí y qué se deja para 11-03

Se construye ahora Se deja para 11-03
Variables de diseño, reinicio y tema
Componentes base (Boton, Campo, Panel, Etiqueta, Modal)
Diseno, Cabecera, MigasDePan, PieDePagina
Las ocho pantallas con su composición completa
Estados vacío, de carga y de error maquetados Que se activen con datos reales
Formularios con su marcado accesible La validación conectada y el envío
Datos importados de src/datos/dominio.js fetch, TanStack Query, Redux
Interacción local (useState para pestañas y modales) El filtro en la URL, la sesión real

Una consecuencia práctica: en esta lección RutaProtegida deja pasar a todo el mundo. Se escribe el componente, se coloca en el árbol de rutas y devuelve <Outlet /> sin comprobar nada, con un comentario que lo señala. Así se puede navegar a /reservas y a /taller para revisar su aspecto, y en 11-03 solo hay que rellenar la condición.

  1. El sistema de diseño: index.css

Todo lo visual del proyecto sale de aquí. Un fichero, unas cuantas variables, y ninguna decisión de color tomada dos veces.

/* src/index.css */

/* ---------- 1. Reinicio mínimo ---------- */
*,
*::before,
*::after {
  box-sizing: border-box;
}

* {
  margin: 0;
}

html {
  -webkit-text-size-adjust: 100%;
}

body {
  min-height: 100vh;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

img,
picture,
svg {
  display: block;
  max-width: 100%;
}

input,
button,
textarea,
select {
  font: inherit;
  color: inherit;
}

h1, h2, h3, h4 {
  line-height: 1.2;
  text-wrap: balance;
}

/* ---------- 2. Variables de diseño ---------- */
:root {
  /* Color */
  --color-marca: #12805c;
  --color-marca-oscura: #0d6247;
  --color-alquilada: #b45309;
  --color-mantenimiento: #9b1c1c;
  --color-fondo: #f5f7fa;
  --color-superficie: #ffffff;
  --color-texto: #1f2933;
  --color-texto-suave: #52606d;
  --color-borde: #d9e2ec;
  --color-foco: #2563eb;

  /* Forma */
  --radio: 8px;
  --sombra: 0 1px 3px rgba(15, 23, 42, 0.08), 0 1px 2px rgba(15, 23, 42, 0.04);
  --sombra-elevada: 0 10px 25px rgba(15, 23, 42, 0.15);

  /* Espaciado: una escala, no números sueltos */
  --espacio: 1rem;
  --espacio-xs: 0.25rem;
  --espacio-s: 0.5rem;
  --espacio-l: 1.5rem;
  --espacio-xl: 2.5rem;

  /* Tipografía */
  --fuente: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
  --texto-s: 0.875rem;
  --texto-m: 1rem;
  --texto-l: 1.25rem;
  --texto-xl: 1.75rem;

  /* Otros */
  --ancho-maximo: 72rem;
  --transicion: 150ms ease;
}

/* ---------- 3. Base ---------- */
body {
  font-family: var(--fuente);
  background-color: var(--color-fondo);
  color: var(--color-texto);
}

/* Foco visible SIEMPRE. Nunca outline: none sin sustituto */
:focus-visible {
  outline: 3px solid var(--color-foco);
  outline-offset: 2px;
  border-radius: 2px;
}

/* Respeto por quien pide menos movimiento */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

Tres decisiones que conviene razonar, porque se aplican en cada componente a partir de ahora:

  • La escala de espaciado tiene cinco valores y ninguno más. En cuanto se admite un padding: 13px porque «quedaba mejor», el sistema deja de existir y cada pantalla acaba con su propio ritmo visual. Si un valor no encaja, se ajusta la escala, no el componente.
  • :focus-visible en el reinicio, con estilo propio. El anillo del navegador se pierde en cuanto cambias los colores de un botón; definirlo una vez aquí garantiza que ninguna parte de la aplicación puede quedarse sin indicador de foco, que es el fallo de accesibilidad más frecuente y más grave en aplicaciones de este tipo (03-06).
  • prefers-reduced-motion desde el primer día. Cuesta seis líneas, y evita que las transiciones provoquen molestias reales a quien las ha desactivado en su sistema.

  1. Tema claro y oscuro con data-tema

El tema se implementa con un atributo en el elemento raíz y una redefinición de variables. Ni una sola regla de componente cambia.

/* src/index.css — continuación */
:root[data-tema='oscuro'] {
  --color-fondo: #131a22;
  --color-superficie: #1b242f;
  --color-texto: #e4ecf3;
  --color-texto-suave: #9aa5b1;
  --color-borde: #2c3846;
  --color-marca: #1fa87a;         /* más luminoso: sobre fondo oscuro necesita más contraste */
  --color-alquilada: #d97706;
  --color-mantenimiento: #ef4444;
  --sombra: 0 1px 3px rgba(0, 0, 0, 0.4);
  --sombra-elevada: 0 10px 25px rgba(0, 0, 0, 0.5);
}

El proveedor, que en esta lección solo guarda el valor en memoria y en localStorage:

// src/contextos/ProveedorTema.jsx
import { createContext, useContext, useEffect, useMemo, useCallback } from 'react';
import { useAlmacenLocal } from '../hooks/useAlmacenLocal.js';

const ContextoTema = createContext(null);

export function ProveedorTema({ children }) {
  const [tema, setTema] = useAlmacenLocal('ciclourbano:tema', 'claro');

  // Efecto de sincronización con el DOM: el atributo vive fuera de React
  useEffect(() => {
    document.documentElement.dataset.tema = tema;
  }, [tema]);

  const alternarTema = useCallback(() => {
    setTema((actual) => (actual === 'claro' ? 'oscuro' : 'claro'));
  }, [setTema]);

  // value estabilizado: sin esto, cada render del proveedor repinta TODOS los consumidores
  const valor = useMemo(() => ({ tema, alternarTema }), [tema, alternarTema]);

  return <ContextoTema.Provider value={valor}>{children}</ContextoTema.Provider>;
}

export function useTema() {
  const contexto = useContext(ContextoTema);
  if (contexto === null) {
    throw new Error('useTema debe usarse dentro de ProveedorTema.');
  }
  return contexto;
}

Dos detalles que ya se explicaron y aquí se aplican sin discusión: el value memorizado de 07-02, que evita que todo el árbol se repinte cuando el proveedor se renderice por cualquier motivo; y el guardián en el hook, que convierte el error silencioso de usar useTema fuera del proveedor —donde el contexto valdría null y reventaría con un mensaje incomprensible— en un error explícito que dice exactamente qué falta.

  1. Los componentes base y su contrato de props

Cinco componentes. Todo lo demás se construye encima. Su valor no está en el código, que es trivial, sino en que existan y se usen: sin ellos, en dos semanas hay siete botones distintos y ninguno se parece a los demás.

Primero, la utilidad que combina clases, ya conocida del módulo 2:

// src/utilidades/clases.js
export function clases(...valores) {
  return valores.filter(Boolean).join(' ');
}

Boton

// src/componentes/base/Boton.jsx
import { clases } from '../../utilidades/clases.js';
import estilos from './Boton.module.css';

function Boton({
  variante = 'primario',
  tamano = 'medio',
  tipo = 'button',
  cargando = false,
  anchoCompleto = false,
  children,
  className,
  ...resto
}) {
  return (
    <button
      type={tipo}
      className={clases(
        estilos.boton,
        estilos[variante],
        estilos[tamano],
        anchoCompleto && estilos.anchoCompleto,
        className
      )}
      aria-busy={cargando || undefined}
      disabled={cargando || resto.disabled}
      {...resto}
    >
      {cargando ? 'Un momento…' : children}
    </button>
  );
}

export default Boton;
Prop Tipo Por defecto Para qué
variante 'primario' | 'secundario' | 'peligro' | 'texto' 'primario' Jerarquía visual de la acción
tamano 'pequeno' | 'medio' | 'grande' 'medio' Densidad según el contexto
tipo 'button' | 'submit' 'button' Por defecto button: evita envíos accidentales
cargando boolean false Deshabilita y marca aria-busy
anchoCompleto boolean false Ocupa todo el ancho (móvil, formularios)
...resto onClick, disabled, aria-*, data-testid

Tres decisiones del contrato que merecen explicación:

  1. type="button" por defecto. El valor por defecto del HTML es submit, y ese detalle provoca uno de los fallos más difíciles de diagnosticar del desarrollo web: un botón secundario dentro de un formulario que lo envía al pulsarlo. Invertir el valor por defecto y exigir tipo="submit" explícito lo elimina de raíz.
  2. {...resto} al final. Permite pasar cualquier atributo nativo sin ampliar el contrato, y al ir después de className deja que quien lo usa sobrescriba lo que necesite. Es composición, no configuración (04-02).
  3. cargando deshabilita además de indicar. Un botón que dice «Un momento…» pero se puede seguir pulsando produce reservas duplicadas. El estado visual y el funcional no se separan.

Campo

El componente que garantiza que no puede existir un control sin etiqueta en toda la aplicación.

// src/componentes/base/Campo.jsx
import { useId } from 'react';
import { clases } from '../../utilidades/clases.js';
import estilos from './Campo.module.css';

function Campo({ etiqueta, error, ayuda, obligatorio = false, children }) {
  const id = useId();
  const idError = `${id}-error`;
  const idAyuda = `${id}-ayuda`;

  // Se describe con la ayuda y, si lo hay, con el error
  const descritoPor = clases(ayuda && idAyuda, error && idError) || undefined;

  return (
    <div className={clases(estilos.campo, error && estilos.conError)}>
      <label className={estilos.etiqueta} htmlFor={id}>
        {etiqueta}
        {obligatorio && (
          <span className={estilos.obligatorio} aria-hidden="true">
            {' '}*
          </span>
        )}
      </label>

      {ayuda && (
        <p className={estilos.ayuda} id={idAyuda}>
          {ayuda}
        </p>
      )}

      {/* El control se recibe como función para poder inyectarle id y aria-* */}
      {children({
        id,
        'aria-invalid': error ? true : undefined,
        'aria-describedby': descritoPor,
        'aria-required': obligatorio || undefined
      })}

      {error && (
        <p className={estilos.error} id={idError} role="alert">
          {error}
        </p>
      )}
    </div>
  );
}

export default Campo;
// Uso
<Campo etiqueta="Duración (horas)" obligatorio error={errores.horas} ayuda="Entre 1 y 24 horas">
  {(props) => (
    <input {...props} type="number" min="1" max="24" value={horas} onChange={manejarHoras} />
  )}
</Campo>

El patrón de hijos como función (04-02) resuelve aquí un problema real: Campo necesita generar un id único con useId y pasárselo al control para el htmlFor, pero no sabe si el control es un input, un select o un textarea. Pasando las props calculadas a una función, quien lo usa decide el elemento y Campo garantiza el cableado accesible. El resultado es que es más fácil escribir un campo accesible que uno que no lo sea, que es la única forma de que la accesibilidad sobreviva a las prisas.

Panel, Etiqueta y Modal

// src/componentes/base/Panel.jsx
import { clases } from '../../utilidades/clases.js';
import estilos from './Panel.module.css';

function Panel({ titulo, nivel = 2, acciones, children, className }) {
  const Encabezado = `h${nivel}`;   // el nivel lo decide la pantalla, no el componente

  return (
    <section className={clases(estilos.panel, className)}>
      {(titulo || acciones) && (
        <header className={estilos.cabecera}>
          {titulo && <Encabezado className={estilos.titulo}>{titulo}</Encabezado>}
          {acciones && <div className={estilos.acciones}>{acciones}</div>}
        </header>
      )}
      <div className={estilos.cuerpo}>{children}</div>
    </section>
  );
}

export default Panel;
// src/componentes/base/Etiqueta.jsx  (EtiquetaEstado es su especialización)
import { clases } from '../../utilidades/clases.js';
import estilos from './Etiqueta.module.css';

const TEXTOS = {
  disponible: 'Disponible',
  alquilada: 'Alquilada',
  mantenimiento: 'En mantenimiento'
};

export function EtiquetaEstado({ estado }) {
  return (
    <span className={clases(estilos.etiqueta, estilos[estado])}>
      {TEXTOS[estado] ?? estado}
    </span>
  );
}

La prop nivel de Panel parece un detalle y no lo es: si Panel fijara siempre un <h2>, una pantalla con paneles anidados produciría una jerarquía de encabezados rota, que es como quien navega con lector de pantalla se pierde. El componente no puede saber en qué nivel del documento lo van a colocar; quien lo coloca, sí.

Y Modal, con las cuatro obligaciones de un diálogo (03-06):

// src/componentes/base/Modal.jsx
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useEventoTeclado } from '../../hooks/useEventoTeclado.js';
import estilos from './Modal.module.css';

function Modal({ abierto, titulo, alCerrar, children }) {
  const contenedor = useRef(null);
  const elementoPrevio = useRef(null);

  useEventoTeclado('Escape', alCerrar, { activo: abierto });

  useEffect(() => {
    if (!abierto) return;

    // 1) Recordar el foco de origen y 2) llevarlo al diálogo
    elementoPrevio.current = document.activeElement;
    contenedor.current?.focus();

    // 3) Bloquear el desplazamiento del fondo
    const overflowPrevio = document.body.style.overflow;
    document.body.style.overflow = 'hidden';

    return () => {
      document.body.style.overflow = overflowPrevio;
      // 4) Devolver el foco a donde estaba
      elementoPrevio.current?.focus?.();
    };
  }, [abierto]);

  if (!abierto) return null;

  return createPortal(
    <div className={estilos.velo} onClick={alCerrar}>
      <div
        ref={contenedor}
        className={estilos.dialogo}
        role="dialog"
        aria-modal="true"
        aria-labelledby="titulo-modal"
        tabIndex={-1}
        onClick={(evento) => evento.stopPropagation()}
      >
        <h2 id="titulo-modal" className={estilos.titulo}>{titulo}</h2>
        {children}
      </div>
    </div>,
    document.body
  );
}

export default Modal;

El createPortal (05-03) es lo que evita que el diálogo quede atrapado dentro de un contenedor con overflow: hidden o con un z-index que lo tape. El stopPropagation del interior impide que un clic dentro del diálogo llegue al velo y lo cierre.

  1. Diseno: el marco de la aplicación

// src/componentes/Diseno.jsx
import { Outlet } from 'react-router';
import Cabecera from './Cabecera.jsx';
import MigasDePan from './MigasDePan.jsx';
import ListaAvisos from './ListaAvisos.jsx';
import PieDePagina from './PieDePagina.jsx';
import estilos from './Diseno.module.css';

function Diseno() {
  return (
    <div className={estilos.contenedor}>
      <a className={estilos.saltar} href="#contenido">
        Saltar al contenido principal
      </a>

      <Cabecera />

      <main className={estilos.principal} id="contenido" tabIndex={-1}>
        <MigasDePan />
        <ListaAvisos />
        <Outlet />
      </main>

      <PieDePagina />
    </div>
  );
}

export default Diseno;
/* src/componentes/Diseno.module.css */
.contenedor {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
}

.principal {
  flex: 1;
  width: 100%;
  max-width: var(--ancho-maximo);
  margin: 0 auto;
  padding: var(--espacio-l) var(--espacio);
}

/* El enlace de salto: fuera de pantalla hasta que recibe el foco */
.saltar {
  position: absolute;
  left: -9999px;
  z-index: 100;
  padding: var(--espacio-s) var(--espacio);
  background: var(--color-marca);
  color: #fff;
  border-radius: 0 0 var(--radio) 0;
  text-decoration: none;
}

.saltar:focus {
  left: 0;
  top: 0;
}

Cinco cosas de esta plantilla que valen para toda la aplicación:

  1. El enlace de salto es el primer elemento enfocable del documento. Quien navega con teclado no tiene que atravesar los seis enlaces de la navegación en cada pantalla. Está oculto visualmente y aparece al recibir el foco: es el patrón estándar y no molesta a nadie más.
  2. min-height: 100vh con flex: 1 en el main mantiene el pie abajo aunque la página tenga tres líneas. Sin esto, la pantalla de «sin reservas» deja el pie flotando a media altura.
  3. tabIndex={-1} en el main lo hace enfocable por programa —no por tabulación— para que el enlace de salto pueda llevar el foco de verdad. Sin ese atributo, el navegador mueve el desplazamiento pero deja el foco donde estaba, y la siguiente tabulación vuelve al principio.
  4. ListaAvisos va dentro del main y antes del Outlet. Los mensajes de resultado deben aparecer en el flujo del contenido, no como una capa flotante que un lector de pantalla anuncie fuera de contexto.
  5. El ancho máximo vive aquí, no en cada pantalla. Una pantalla que necesite todo el ancho lo pedirá explícitamente; el resto lo hereda.

  1. Navegación con NavLink y migas de pan

// src/componentes/Cabecera.jsx
import { NavLink, Link } from 'react-router';
import MenuUsuario from './MenuUsuario.jsx';
import BotonTema from './BotonTema.jsx';
import { clases } from '../utilidades/clases.js';
import { NOMBRE_APP } from '../configuracion.js';
import estilos from './Cabecera.module.css';

const ENLACES = [
  { a: '/', texto: 'Catálogo', exacto: true },
  { a: '/estaciones', texto: 'Estaciones' },
  { a: '/reservas', texto: 'Mis reservas' },
  { a: '/taller', texto: 'Taller', soloOperario: true }
];

function Cabecera() {
  // En 11-03 saldrá de la sesión real; ahora se fija para poder ver la pantalla
  const esOperario = true;

  const visibles = ENLACES.filter((enlace) => !enlace.soloOperario || esOperario);

  return (
    <header className={estilos.cabecera}>
      <div className={estilos.interior}>
        <Link to="/" className={estilos.marca}>
          <span aria-hidden="true">🚲</span> {NOMBRE_APP}
        </Link>

        <nav className={estilos.navegacion} aria-label="Principal">
          <ul className={estilos.lista}>
            {visibles.map((enlace) => (
              <li key={enlace.a}>
                <NavLink
                  to={enlace.a}
                  end={enlace.exacto}
                  className={({ isActive }) =>
                    clases(estilos.enlace, isActive && estilos.activo)
                  }
                >
                  {enlace.texto}
                </NavLink>
              </li>
            ))}
          </ul>
        </nav>

        <div className={estilos.acciones}>
          <BotonTema />
          <MenuUsuario />
        </div>
      </div>
    </header>
  );
}

export default Cabecera;
/* src/componentes/Cabecera.module.css — el estado activo */
.enlace {
  display: block;
  padding: var(--espacio-s) var(--espacio);
  color: var(--color-texto-suave);
  text-decoration: none;
  border-radius: var(--radio);
  transition: background-color var(--transicion);
}

.enlace:hover {
  background-color: var(--color-fondo);
  color: var(--color-texto);
}

.activo {
  color: var(--color-marca);
  font-weight: 600;
  /* El color NO puede ser la única señal: se añade una marca de forma */
  box-shadow: inset 0 -2px 0 var(--color-marca);
}

Dos puntos que se decidieron en el módulo 6 y aquí se recogen:

  • end en el enlace al catálogo. Sin él, / coincide con todas las rutas —porque NavLink compara por prefijo— y el enlace del catálogo aparecería activo estando en /reservas.
  • El estado activo no se señala solo con color. Una persona con daltonismo o con un monitor en escala de grises no distingue el enlace actual si la única diferencia es el tono. El subrayado grueso lo resuelve, y no cuesta nada.

Las migas de pan aprovechan el handle que ya llevaba cada ruta:

// src/componentes/MigasDePan.jsx
import { Link, useMatches } from 'react-router';
import estilos from './MigasDePan.module.css';

function MigasDePan() {
  const coincidencias = useMatches().filter((m) => m.handle?.miga);

  if (coincidencias.length <= 1) return null;   // en el inicio no aportan nada

  return (
    <nav aria-label="Ruta de navegación" className={estilos.migas}>
      <ol className={estilos.lista}>
        {coincidencias.map((coincidencia, indice) => {
          const esUltima = indice === coincidencias.length - 1;
          const texto =
            typeof coincidencia.handle.miga === 'function'
              ? coincidencia.handle.miga(coincidencia)
              : coincidencia.handle.miga;

          return (
            <li key={coincidencia.id} className={estilos.miga}>
              {esUltima ? (
                <span aria-current="page">{texto}</span>
              ) : (
                <>
                  <Link to={coincidencia.pathname}>{texto}</Link>
                  <span aria-hidden="true" className={estilos.separador}>/</span>
                </>
              )}
            </li>
          );
        })}
      </ol>
    </nav>
  );
}

export default MigasDePan;

El separador / es decorativo y lleva aria-hidden: sin él, un lector de pantalla leería «Inicio barra Estaciones barra Plaza Mayor». El último elemento no es un enlace —no tiene sentido enlazar a la página en la que ya estás— y se marca con aria-current="page".

  1. Accesibilidad estructural: puntos de referencia y salto al contenido

Antes de escribir pantallas, conviene fijar la estructura del documento que todas van a compartir:

flowchart TD
    BODY["body"]
    BODY --> SALTO["a.saltar → #contenido"]
    BODY --> HEAD["header · banner"]
    HEAD --> NAV["nav aria-label='Principal'"]
    HEAD --> ACC["Acciones: tema y usuario"]
    BODY --> MAIN["main#contenido · main"]
    MAIN --> MIGAS["nav aria-label='Ruta de navegación'"]
    MAIN --> AVISOS["ListaAvisos · role='status'"]
    MAIN --> OUT["Outlet: h1 de la pantalla + secciones"]
    BODY --> PIE["footer · contentinfo"]

Las reglas que se aplican en todas las pantallas sin excepción:

Regla Por qué
Un solo <h1> por pantalla, y es el título de la pantalla Es el ancla de orientación de cualquier lector de pantalla
Los niveles no se saltan (h1 → h2 → h3) Saltar de h1 a h3 sugiere una sección inexistente
Cada <nav> con su aria-label Hay dos navegaciones; sin etiqueta son indistinguibles en la lista de puntos de referencia
Solo un <main> por documento Es el destino del enlace de salto
Los iconos decorativos con aria-hidden="true" «Emoji bicicleta Catálogo» no ayuda a nadie
Los botones con texto real, no solo icono Y si el icono va solo, aria-label obligatorio

  1. Los datos de ejemplo mientras no hay red

// src/datos/dominio.js — copia estática de db.json para trabajar sin API
export const bicicletas = [
  { id: 'bici-001', modelo: 'Urbana Clásica', tipo: 'urbana', estado: 'disponible', estacionId: 'est-01', precioHora: 2.5 },
  { id: 'bici-002', modelo: 'Eléctrica Pro', tipo: 'electrica', estado: 'alquilada', estacionId: 'est-01', precioHora: 4.0 },
  { id: 'bici-003', modelo: 'Carga Max', tipo: 'carga', estado: 'mantenimiento', estacionId: 'est-02', precioHora: 5.5 },
  { id: 'bici-004', modelo: 'Urbana Clásica', tipo: 'urbana', estado: 'disponible', estacionId: 'est-03', precioHora: 2.5 },
  { id: 'bici-005', modelo: 'Eléctrica Pro', tipo: 'electrica', estado: 'disponible', estacionId: 'est-02', precioHora: 4.0 }
];

export const estaciones = [
  { id: 'est-01', nombre: 'Plaza Mayor', barrio: 'Centro', plazas: 20 },
  { id: 'est-02', nombre: 'Parque Norte', barrio: 'Norte', plazas: 15 },
  { id: 'est-03', nombre: 'Estación Central', barrio: 'Ensanche', plazas: 30 }
];

export const usuarios = [
  { id: 'usr-01', nombre: 'Ana Ribera', email: '[email protected]', rol: 'cliente' },
  { id: 'usr-02', nombre: 'Marc Solé', email: '[email protected]', rol: 'operario' }
];

export const reservas = [
  { id: 'res-01', bicicletaId: 'bici-002', usuario: 'usr-01', fechaInicio: '2026-05-04T09:00', horas: 2, estado: 'activa' }
];

Es una copia literal de db.json, a propósito. Cuando 11-03 sustituya estas importaciones por hooks de consulta, la forma de los datos será idéntica y ningún componente tendrá que cambiar. Ese es el motivo de mantener la duplicación en lugar de inventar datos nuevos aquí.

El fichero no se borra en 11-03: pasa a usarse en las pruebas y en el db.json de siembra de Cypress.

  1. Los cuatro estados de cada pantalla

Cada pantalla que muestre datos tiene cuatro caras. Escribirlas ahora cuesta minutos; añadirlas después, cuando la lógica ya está entrelazada, cuesta horas.

Estado Qué se ve Componente
Cargando Bloques grises con la forma del contenido final EsqueletoPagina
Vacío Explicación de por qué no hay nada y qué hacer Bloque propio de la pantalla
Error Mensaje comprensible y botón de reintento Aviso con role="alert"
Con datos El contenido La pantalla
// src/componentes/EsqueletoPagina.jsx
import estilos from './EsqueletoPagina.module.css';

function EsqueletoPagina({ filas = 3, conCabecera = true }) {
  return (
    <div className={estilos.esqueleto} data-testid="esqueleto-pagina" aria-hidden="true">
      {conCabecera && <div className={estilos.titulo} />}
      {Array.from({ length: filas }, (_, indice) => (
        <div key={indice} className={estilos.fila} />
      ))}
    </div>
  );
}

export default EsqueletoPagina;
// El anuncio para lectores de pantalla va aparte, porque el esqueleto es aria-hidden
function IndicadorDeCarga({ texto = 'Cargando…' }) {
  return (
    <p role="status" aria-live="polite" className="visualmente-oculto">
      {texto}
    </p>
  );
}

El reparto es deliberado: el esqueleto es información puramente visual y para un lector de pantalla es ruido —anunciaría una docena de contenedores vacíos—, así que se oculta con aria-hidden. La información equivalente se da con un texto en role="status" que es invisible pero se anuncia. Dos canales, un mismo mensaje.

Y el estado vacío, que es el que más se descuida:

// Mal: no dice nada
<p>No hay reservas.</p>

// Bien: explica y ofrece la salida
<div className={estilos.vacio}>
  <h2>Todavía no tienes reservas</h2>
  <p>Cuando reserves una bicicleta aparecerá aquí con su hora de inicio y su duración.</p>
  <Boton onClick={() => navegar('/')}>Ver el catálogo</Boton>
</div>

  1. Pantalla 1: el catálogo

La pantalla más visitada y la que más piezas compone. Aquí, con estado local; en 11-03, el filtro se muda a la URL y los datos a TanStack Query.

// src/paginas/PaginaCatalogo.jsx
import { useState, useMemo, useDeferredValue } from 'react';
import Panel from '../componentes/base/Panel.jsx';
import SelectorTipo from '../componentes/SelectorTipo.jsx';
import BuscadorBicicletas from '../componentes/BuscadorBicicletas.jsx';
import ListaBicicletas from '../componentes/ListaBicicletas.jsx';
import ResumenFlota from '../componentes/ResumenFlota.jsx';
import EsqueletoPagina from '../componentes/EsqueletoPagina.jsx';
import Aviso from '../componentes/Aviso.jsx';
import { bicicletas, estaciones } from '../datos/dominio.js';
import estilos from './PaginaCatalogo.module.css';

// Interruptores temporales para revisar los estados. En 11-03 los sustituyen isPending e isError
const CARGANDO = false;
const CON_ERROR = false;

function PaginaCatalogo() {
  const [tipo, setTipo] = useState('todos');
  const [termino, setTermino] = useState('');

  // El término diferido mantiene el campo fluido con listas grandes (08-03)
  const terminoDiferido = useDeferredValue(termino);

  const visibles = useMemo(() => {
    const texto = terminoDiferido.trim().toLowerCase();
    return bicicletas
      .filter((bici) => tipo === 'todos' || bici.tipo === tipo)
      .filter((bici) => bici.modelo.toLowerCase().includes(texto));
  }, [tipo, terminoDiferido]);

  if (CARGANDO) return <EsqueletoPagina filas={5} />;

  if (CON_ERROR) {
    return (
      <Aviso tono="error" titulo="No se han podido cargar las bicicletas">
        Comprueba tu conexión e inténtalo de nuevo.
      </Aviso>
    );
  }

  return (
    <>
      <h1 className={estilos.titulo}>Catálogo de bicicletas</h1>
      <p className={estilos.entradilla}>
        {visibles.length} de {bicicletas.length} bicicletas
      </p>

      <ResumenFlota bicicletas={bicicletas} />

      <Panel titulo="Filtros" nivel={2} className={estilos.filtros}>
        <SelectorTipo tipoElegido={tipo} alCambiarTipo={setTipo} />
        <BuscadorBicicletas termino={termino} alCambiarTermino={setTermino} />
      </Panel>

      {visibles.length === 0 ? (
        <div className={estilos.vacio}>
          <h2>Ninguna bicicleta coincide con la búsqueda</h2>
          <p>Prueba con otro tipo o borra el texto del buscador.</p>
        </div>
      ) : (
        <ListaBicicletas bicicletas={visibles} estaciones={estaciones} />
      )}
    </>
  );
}

export default PaginaCatalogo;

SelectorTipo como grupo de botones, con useTransition para que el cambio de filtro no bloquee la interfaz (08-01):

// src/componentes/SelectorTipo.jsx
import { useTransition } from 'react';
import { clases } from '../utilidades/clases.js';
import estilos from './SelectorTipo.module.css';

const TIPOS = [
  { valor: 'todos', texto: 'Todas' },
  { valor: 'urbana', texto: 'Urbana' },
  { valor: 'electrica', texto: 'Eléctrica' },
  { valor: 'carga', texto: 'De carga' }
];

function SelectorTipo({ tipoElegido, alCambiarTipo }) {
  const [pendiente, iniciarTransicion] = useTransition();

  return (
    <div
      className={clases(estilos.grupo, pendiente && estilos.pendiente)}
      role="group"
      aria-label="Filtrar por tipo de bicicleta"
    >
      {TIPOS.map((tipo) => (
        <button
          key={tipo.valor}
          type="button"
          className={clases(estilos.boton, tipoElegido === tipo.valor && estilos.activo)}
          aria-pressed={tipoElegido === tipo.valor}
          onClick={() => iniciarTransicion(() => alCambiarTipo(tipo.valor))}
        >
          {tipo.texto}
        </button>
      ))}
    </div>
  );
}

export default SelectorTipo;

aria-pressed es lo que convierte cuatro botones sueltos en un grupo de alternancia comprensible: un lector de pantalla anuncia «Eléctrica, botón de alternancia, activado», que es exactamente lo que ve quien mira la pantalla.

Y la tarjeta, memorizada porque es el elemento que más se repite (08-02):

// src/componentes/TarjetaBicicleta.jsx
import { memo } from 'react';
import { Link } from 'react-router';
import { EtiquetaEstado } from './base/Etiqueta.jsx';
import Boton from './base/Boton.jsx';
import estilos from './TarjetaBicicleta.module.css';

function TarjetaBicicleta({ bicicleta, estacion, alReservar }) {
  const disponible = bicicleta.estado === 'disponible';

  return (
    <article
      className={estilos.tarjeta}
      data-testid="tarjeta-bicicleta"
      data-bicicleta={bicicleta.id}
    >
      <header className={estilos.cabecera}>
        <h3 className={estilos.modelo}>
          <Link to={`/bicicletas/${bicicleta.id}`}>{bicicleta.modelo}</Link>
        </h3>
        <EtiquetaEstado estado={bicicleta.estado} />
      </header>

      <dl className={estilos.datos}>
        <div>
          <dt>Estación</dt>
          <dd>{estacion?.nombre ?? 'Sin asignar'}</dd>
        </div>
        <div>
          <dt>Precio</dt>
          <dd>{bicicleta.precioHora.toFixed(2)} €/h</dd>
        </div>
      </dl>

      <Boton
        variante="primario"
        disabled={!disponible}
        onClick={() => alReservar?.(bicicleta.id)}
        aria-label={`Reservar ${bicicleta.modelo}`}
      >
        {disponible ? 'Reservar' : 'No disponible'}
      </Boton>
    </article>
  );
}

export default memo(TarjetaBicicleta);

Fíjate en el <dl>: modelo y precio no son párrafos sueltos, son pares de término y definición, y marcarlos así hace que un lector de pantalla anuncie «Estación: Plaza Mayor» en lugar de dos textos inconexos. El aria-label del botón resuelve el problema de los «cinco botones que dicen Reservar»: al navegar por la lista de botones, cada uno dice cuál es.

  1. Pantalla 2: la ficha de una bicicleta

// src/paginas/PaginaFichaBicicleta.jsx
import { useParams, useNavigate, Link } from 'react-router';
import Panel from '../componentes/base/Panel.jsx';
import Boton from '../componentes/base/Boton.jsx';
import { EtiquetaEstado } from '../componentes/base/Etiqueta.jsx';
import { bicicletas, estaciones } from '../datos/dominio.js';
import estilos from './PaginaFichaBicicleta.module.css';

function PaginaFichaBicicleta() {
  const { bicicletaId } = useParams();
  const navegar = useNavigate();

  const bicicleta = bicicletas.find((b) => b.id === bicicletaId);

  // El identificador inexistente NO es una pantalla en blanco (H3)
  if (!bicicleta) {
    return (
      <div className={estilos.noEncontrada}>
        <h1>Esta bicicleta no existe</h1>
        <p>El identificador <code>{bicicletaId}</code> no corresponde a ninguna bicicleta.</p>
        <Link to="/">Volver al catálogo</Link>
      </div>
    );
  }

  const estacion = estaciones.find((e) => e.id === bicicleta.estacionId);
  const disponible = bicicleta.estado === 'disponible';

  return (
    <>
      <header className={estilos.cabecera}>
        <h1>{bicicleta.modelo}</h1>
        <EtiquetaEstado estado={bicicleta.estado} />
      </header>

      <div className={estilos.columnas}>
        <Panel titulo="Datos de la bicicleta" nivel={2}>
          <dl className={estilos.datos}>
            <div><dt>Identificador</dt><dd>{bicicleta.id}</dd></div>
            <div><dt>Tipo</dt><dd>{bicicleta.tipo}</dd></div>
            <div><dt>Precio por hora</dt><dd>{bicicleta.precioHora.toFixed(2)} €</dd></div>
          </dl>
        </Panel>

        <Panel titulo="Ubicación" nivel={2}>
          {estacion ? (
            <p>
              <Link to={`/estaciones/${estacion.id}`}>{estacion.nombre}</Link>
              {' · '}Barrio {estacion.barrio} · {estacion.plazas} plazas
            </p>
          ) : (
            <p>Esta bicicleta no está asignada a ninguna estación.</p>
          )}
        </Panel>
      </div>

      <div className={estilos.acciones}>
        <Boton
          disabled={!disponible}
          onClick={() => navegar(`/reservas/nueva?bicicleta=${bicicleta.id}`)}
        >
          {disponible ? 'Reservar esta bicicleta' : 'No disponible ahora mismo'}
        </Boton>
        {!disponible && (
          <p className={estilos.motivo}>
            {bicicleta.estado === 'alquilada'
              ? 'Está alquilada. Vuelve a consultarla más tarde.'
              : 'Está en mantenimiento. El equipo del taller la está revisando.'}
          </p>
        )}
      </div>
    </>
  );
}

export default PaginaFichaBicicleta;

Dos detalles de producto, no de código: el botón deshabilitado explica por qué lo está. Un control apagado sin explicación es la forma más rápida de frustrar a alguien. Y la reserva se preselecciona por la URL (?bicicleta=bici-001), de modo que quien llega desde la ficha no tiene que volver a elegir la bicicleta en el formulario.

  1. Pantallas 3 y 4: estaciones y detalle con pestañas

// src/paginas/PaginaEstaciones.jsx
import TarjetaEstacion from '../componentes/TarjetaEstacion.jsx';
import { estaciones, bicicletas } from '../datos/dominio.js';
import estilos from './PaginaEstaciones.module.css';

function PaginaEstaciones() {
  return (
    <>
      <h1>Estaciones</h1>
      <p className={estilos.entradilla}>{estaciones.length} estaciones en servicio.</p>

      <ul className={estilos.rejilla}>
        {estaciones.map((estacion) => {
          const deLaEstacion = bicicletas.filter((b) => b.estacionId === estacion.id);
          return (
            <li key={estacion.id}>
              <TarjetaEstacion
                estacion={estacion}
                totalBicicletas={deLaEstacion.length}
                disponibles={deLaEstacion.filter((b) => b.estado === 'disponible').length}
              />
            </li>
          );
        })}
      </ul>
    </>
  );
}

export default PaginaEstaciones;

El detalle usa rutas anidadas para las pestañas (06-03), no estado local. Así la pestaña de incidencias es enlazable y el botón atrás funciona:

// src/paginas/PaginaDetalleEstacion.jsx
import { NavLink, Outlet, useParams } from 'react-router';
import { clases } from '../utilidades/clases.js';
import { estaciones } from '../datos/dominio.js';
import estilos from './PaginaDetalleEstacion.module.css';

function PaginaDetalleEstacion() {
  const { estacionId } = useParams();
  const estacion = estaciones.find((e) => e.id === estacionId);

  if (!estacion) {
    return <h1>Esta estación no existe</h1>;
  }

  const claseEnlace = ({ isActive }) => clases(estilos.pestana, isActive && estilos.activa);

  return (
    <>
      <h1>{estacion.nombre}</h1>
      <p className={estilos.entradilla}>
        Barrio {estacion.barrio} · {estacion.plazas} plazas
      </p>

      <nav className={estilos.pestanas} aria-label="Secciones de la estación">
        <NavLink to="." end className={claseEnlace}>Flota</NavLink>
        <NavLink to="incidencias" className={claseEnlace}>Incidencias</NavLink>
      </nav>

      {/* El contenido de la pestaña activa */}
      <Outlet context={{ estacion }} />
    </>
  );
}

export default PaginaDetalleEstacion;
Pestañas con estado local Pestañas como rutas anidadas
No son enlazables /estaciones/est-01/incidencias se puede compartir
El botón atrás sale de la pantalla El botón atrás vuelve a la pestaña anterior
Se pierden al recargar Sobreviven a la recarga
Todo el contenido se carga siempre Cada pestaña puede cargarse con lazy

  1. Pantalla 5: mis reservas

// src/paginas/PaginaReservas.jsx
import { useState } from 'react';
import { Link } from 'react-router';
import PanelReservas from '../componentes/PanelReservas.jsx';
import Modal from '../componentes/base/Modal.jsx';
import Boton from '../componentes/base/Boton.jsx';
import { reservas, bicicletas } from '../datos/dominio.js';
import estilos from './PaginaReservas.module.css';

function PaginaReservas() {
  // Estado local puro: qué reserva está pendiente de confirmación de cancelación
  const [reservaACancelar, setReservaACancelar] = useState(null);

  return (
    <>
      <div className={estilos.cabecera}>
        <h1>Mis reservas</h1>
        <Link to="/reservas/nueva" className={estilos.enlaceAccion}>
          Nueva reserva
        </Link>
      </div>

      {reservas.length === 0 ? (
        <div className={estilos.vacio}>
          <h2>Todavía no tienes reservas</h2>
          <p>Cuando reserves una bicicleta aparecerá aquí con su hora de inicio y su duración.</p>
          <Link to="/">Ver el catálogo</Link>
        </div>
      ) : (
        <PanelReservas
          reservas={reservas}
          bicicletas={bicicletas}
          alCancelar={setReservaACancelar}
        />
      )}

      <Modal
        abierto={reservaACancelar !== null}
        titulo="Cancelar la reserva"
        alCerrar={() => setReservaACancelar(null)}
      >
        <p>¿Seguro que quieres cancelar esta reserva? Esta acción no se puede deshacer.</p>
        <div className={estilos.accionesModal}>
          <Boton variante="secundario" onClick={() => setReservaACancelar(null)}>
            Volver
          </Boton>
          <Boton variante="peligro" onClick={() => setReservaACancelar(null)}>
            Sí, cancelar
          </Boton>
        </div>
      </Modal>
    </>
  );
}

export default PaginaReservas;

La tabla de reservas, con el anclaje de prueba ya colocado:

// src/componentes/PanelReservas.jsx (extracto)
<table className={estilos.tabla}>
  <caption className="visualmente-oculto">Lista de tus reservas</caption>
  <thead>
    <tr>
      <th scope="col">Bicicleta</th>
      <th scope="col">Inicio</th>
      <th scope="col">Horas</th>
      <th scope="col">Estado</th>
      <th scope="col"><span className="visualmente-oculto">Acciones</span></th>
    </tr>
  </thead>
  <tbody>
    {reservas.map((reserva) => (
      <tr key={reserva.id} data-testid="fila-reserva">
        <td>{bicicletaDe(reserva)?.modelo ?? 'Desconocida'}</td>
        <td>{formatearFecha(reserva.fechaInicio)}</td>
        <td>{reserva.horas}</td>
        <td><EtiquetaEstado estado={reserva.estado} /></td>
        <td>
          <Boton
            variante="texto"
            onClick={() => alCancelar(reserva)}
            disabled={reserva.estado === 'cancelada'}
          >
            Cancelar
          </Boton>
        </td>
      </tr>
    ))}
  </tbody>
</table>

Una tabla de datos debe ser una <table>, no una rejilla de <div>. Con scope="col" en las cabeceras, un lector de pantalla anuncia «Estado: activa» al recorrer la fila; con divs, lee cinco valores sueltos sin saber a qué corresponden. El <caption> oculto visualmente da el contexto de la tabla completa.

  1. Pantalla 6: nueva reserva

El formulario es la pantalla con más carga de accesibilidad de todo el proyecto.

// src/componentes/FormularioReserva.jsx
import { useState } from 'react';
import Campo from './base/Campo.jsx';
import Boton from './base/Boton.jsx';
import estilos from './FormularioReserva.module.css';

function FormularioReserva({ bicicletas, valorInicial, enviando = false, alEnviar }) {
  const [datos, setDatos] = useState(valorInicial);
  const [errores, setErrores] = useState({});

  const reservables = bicicletas.filter((b) => b.estado === 'disponible');
  const elegida = bicicletas.find((b) => b.id === datos.bicicletaId);
  const total = elegida ? elegida.precioHora * Number(datos.horas || 0) : 0;

  function manejarCambio(campo, valor) {
    setDatos((actual) => ({ ...actual, [campo]: valor }));
  }

  function manejarEnviar(evento) {
    evento.preventDefault();
    // En 11-03 aquí entra validarReserva y la mutación
    alEnviar?.(datos);
  }

  return (
    <form className={estilos.formulario} onSubmit={manejarEnviar} noValidate>
      <Campo etiqueta="Bicicleta" obligatorio error={errores.bicicletaId}>
        {(props) => (
          <select
            {...props}
            value={datos.bicicletaId}
            onChange={(e) => manejarCambio('bicicletaId', e.target.value)}
          >
            <option value="">Elige una bicicleta</option>
            {reservables.map((bici) => (
              <option key={bici.id} value={bici.id}>
                {bici.modelo} — {bici.precioHora.toFixed(2)} €/h
              </option>
            ))}
          </select>
        )}
      </Campo>

      <Campo
        etiqueta="Inicio de la reserva"
        obligatorio
        error={errores.fechaInicio}
        ayuda="No puede empezar en el pasado"
      >
        {(props) => (
          <input
            {...props}
            type="datetime-local"
            value={datos.fechaInicio}
            onChange={(e) => manejarCambio('fechaInicio', e.target.value)}
          />
        )}
      </Campo>

      <Campo etiqueta="Duración (horas)" obligatorio error={errores.horas} ayuda="Entre 1 y 24">
        {(props) => (
          <input
            {...props}
            type="number"
            min="1"
            max="24"
            value={datos.horas}
            onChange={(e) => manejarCambio('horas', e.target.value)}
          />
        )}
      </Campo>

      <Campo etiqueta="Acepto las condiciones de uso" error={errores.condiciones}>
        {(props) => (
          <input
            {...props}
            type="checkbox"
            checked={datos.condiciones}
            onChange={(e) => manejarCambio('condiciones', e.target.checked)}
          />
        )}
      </Campo>

      <p className={estilos.total} data-testid="total-reserva">
        Total estimado: <strong>{total.toFixed(2)} €</strong>
      </p>

      <Boton tipo="submit" cargando={enviando} anchoCompleto>
        Crear reserva
      </Boton>
    </form>
  );
}

export default FormularioReserva;

Las decisiones del formulario, todas heredadas de 03-04 y 03-05:

Decisión Motivo
noValidate en el <form> Se desactiva la validación del navegador para usar la propia, con mensajes en español, consistentes y traducibles
El select solo lista bicicletas disponible Impedir el error es mejor que informar de él después
El total se calcula en el render Nunca se guarda en estado un valor derivado (05-01)
Los errores llegan por prop desde Campo El cableado aria-invalid / aria-describedby se resuelve en un solo sitio
tipo="submit" explícito Consecuencia del valor por defecto invertido de Boton
data-testid="total-reserva" Anclaje estable para 11-04

  1. Pantallas 7 y 8: acceso y taller

// src/paginas/PaginaAcceso.jsx (extracto)
function PaginaAcceso() {
  const [correo, setCorreo] = useState('');
  const [error, setError] = useState(null);

  function manejarEnviar(evento) {
    evento.preventDefault();
    // En 11-03: despacho a sliceSesion y redirección al origen
  }

  return (
    <div className={estilos.centro}>
      <h1>Acceder a CicloUrbano</h1>

      {error && (
        <div role="alert" className={estilos.error}>
          {error}
        </div>
      )}

      <form onSubmit={manejarEnviar} noValidate>
        <Campo etiqueta="Correo electrónico" obligatorio ayuda="Prueba con [email protected]">
          {(props) => (
            <input
              {...props}
              type="email"
              autoComplete="email"
              value={correo}
              onChange={(e) => setCorreo(e.target.value)}
            />
          )}
        </Campo>

        <Boton tipo="submit" anchoCompleto>Entrar</Boton>
      </form>
    </div>
  );
}

El autoComplete="email" no es opcional: es lo que permite que el gestor de contraseñas y el autorrelleno del navegador funcionen, y su ausencia es una de las quejas más frecuentes de las personas usuarias en formularios de acceso.

Y el taller, que en esta lección es accesible para poder revisarlo:

// src/componentes/RutaProtegida.jsx — versión de 11-02
import { Outlet } from 'react-router';

function RutaProtegida() {
  // TODO(11-03): comprobar la sesión real y redirigir a /acceso conservando el origen
  return <Outlet />;
}

export default RutaProtegida;
// src/paginas/PaginaTaller.jsx (extracto)
function PaginaTaller() {
  const enMantenimiento = bicicletas.filter((b) => b.estado === 'mantenimiento');
  const resto = bicicletas.filter((b) => b.estado !== 'mantenimiento');

  return (
    <>
      <h1>Taller</h1>
      <p className={estilos.entradilla}>
        Gestión del estado de la flota. Solo para personal operario.
      </p>

      <Panel titulo={`En mantenimiento (${enMantenimiento.length})`} nivel={2}>
        {enMantenimiento.length === 0 ? (
          <p>Toda la flota está operativa.</p>
        ) : (
          <ul className={estilos.lista}>
            {enMantenimiento.map((bici) => (
              <li key={bici.id}>
                {bici.modelo} <code>{bici.id}</code>
                <Boton variante="secundario" tamano="pequeno">Devolver al servicio</Boton>
              </li>
            ))}
          </ul>
        )}
      </Panel>

      <Panel titulo={`Resto de la flota (${resto.length})`} nivel={2}>
        {/* mismo patrón, con el botón "Enviar a mantenimiento" */}
      </Panel>
    </>
  );
}

Fíjate en el orden: lo que el operario necesita ver primero está primero. Es la consecuencia directa del cuadro de personas usuarias de 11-01, y es el tipo de decisión que solo se toma bien si se ha hecho ese trabajo previo.

  1. Las pantallas de error

Tres, y cada una responde a una situación distinta:

// src/paginas/PaginaNoEncontrada.jsx — ruta * : la URL no existe
function PaginaNoEncontrada() {
  return (
    <div className={estilos.centro}>
      <h1>Esta página no existe</h1>
      <p>Puede que el enlace esté mal escrito o que la página se haya movido.</p>
      <Link to="/">Ir al catálogo</Link>
    </div>
  );
}
// src/paginas/PaginaSinPermisos.jsx — la URL existe, pero el rol no basta
function PaginaSinPermisos() {
  return (
    <div className={estilos.centro}>
      <h1>No tienes permiso para ver esta página</h1>
      <p>El área de taller está reservada al personal operario.</p>
      <Link to="/">Volver al catálogo</Link>
    </div>
  );
}
// src/paginas/PaginaErrorRuta.jsx — errorElement: algo ha fallado al renderizar la ruta
import { useRouteError, isRouteErrorResponse, Link } from 'react-router';
import { registrarError } from '../utilidades/monitorizacion.js';

function PaginaErrorRuta() {
  const error = useRouteError();
  registrarError(error, { origen: 'errorElement' });

  const mensaje = isRouteErrorResponse(error)
    ? `Error ${error.status}: ${error.statusText}`
    : 'Ha ocurrido un problema al mostrar esta pantalla.';

  return (
    <div className={estilos.centro} role="alert">
      <h1>Algo no ha ido bien</h1>
      <p>{mensaje}</p>
      <Link to="/">Volver al catálogo</Link>
      {import.meta.env.DEV && <pre className={estilos.detalle}>{String(error)}</pre>}
    </div>
  );
}
Pantalla Cuándo aparece Quién la activa
PaginaNoEncontrada La URL no coincide con ninguna ruta La ruta *
PaginaSinPermisos Hay sesión, pero el rol no basta RequiereRol (en 11-03)
PaginaErrorRuta Excepción al renderizar una ruta errorElement de React Router
LimiteDeError Excepción fuera del enrutador El límite de main.jsx

El detalle técnico del error solo se muestra en desarrollo (import.meta.env.DEV). En producción, una traza de pila en pantalla no ayuda al usuario y sí orienta a quien busque agujeros.

  1. Diseño adaptable con CSS Modules

La rejilla del catálogo, sin un solo cálculo en JavaScript:

/* src/componentes/ListaBicicletas.module.css */
.lista {
  list-style: none;
  padding: 0;
  display: grid;
  gap: var(--espacio);
  /* Tantas columnas como quepan, con un mínimo de 260px cada una */
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
}

Esa única línea sustituye a lo que en otros tiempos eran tres media queries: auto-fill con minmax reparte el espacio disponible sin que nadie tenga que decidir puntos de ruptura. Cuando sí hacen falta, van así:

/* src/componentes/Cabecera.module.css */
.lista {
  display: flex;
  gap: var(--espacio-s);
  list-style: none;
  margin: 0;
  padding: 0;
}

@media (max-width: 48rem) {
  .interior {
    flex-wrap: wrap;
  }

  .navegacion {
    order: 3;
    width: 100%;
  }

  .lista {
    overflow-x: auto;          /* la navegación se desplaza en horizontal */
    scrollbar-width: none;
    padding-bottom: var(--espacio-xs);
  }
}

Por qué las media queries van en el CSS y no en JavaScript, aunque el proyecto tenga un hook useAnchoVentana:

CSS (@media) JavaScript (useAnchoVentana)
Cuándo se aplica En el primer pintado Tras montar y medir: hay un parpadeo
Coste Nulo: lo resuelve el motor de estilos Un evento de resize y un repintado de React
Si el JavaScript falla o tarda Sigue funcionando La maquetación queda mal
Redimensionar la ventana Instantáneo Repinta todo el árbol afectado
Cuándo es la opción correcta Casi siempre Cuando hay que renderizar componentes distintos, no solo estilarlos

En CicloUrbano, useAnchoVentana se reserva para un único caso: decidir si el menú de usuario se despliega hacia abajo o abre un panel a pantalla completa, porque son estructuras de DOM distintas y no una diferencia de estilos. Todo lo demás —rejilla, navegación, tipografías, espaciados— es CSS.

Los tres puntos de ruptura del proyecto, y ni uno más:

Nombre Ancho Qué cambia
Móvil < 48rem Una columna, navegación desplazable, botones a ancho completo
Tableta 48rem – 64rem Dos columnas en el catálogo, navegación en línea
Escritorio > 64rem Rejilla completa, columnas laterales en la ficha

  1. La lista de comprobación de accesibilidad por pantalla

Se aplica a cada pantalla antes de darla por terminada. Ocho puntos, cinco minutos:

# Comprobación Cómo se verifica
1 Un solo <h1> y jerarquía sin saltos Extensión de accesibilidad del navegador, vista de encabezados
2 Se puede completar la tarea solo con el teclado Guarda el ratón y recorre la pantalla con Tab, Enter y Escape
3 El foco es visible en todo momento Mientras tabulas, siempre se ve dónde estás
4 El orden de tabulación sigue el orden visual Si salta hacia atrás, el DOM y el CSS discrepan
5 Todo control tiene nombre accesible Inspector de accesibilidad: ningún elemento sin nombre
6 Los errores se anuncian (role="alert") y se asocian (aria-describedby) Provoca un error y escucha con un lector de pantalla
7 El contraste llega a 4.5:1 en texto normal Herramienta de contraste del navegador, en los dos temas
8 La información no depende solo del color Mira la pantalla en escala de grises

El punto 7 tiene trampa y conviene señalarla: un tema oscuro bien contrastado no se obtiene invirtiendo los colores. --color-marca: #12805c cumple sobre blanco y se queda corto sobre #131a22; por eso el tema oscuro lo sube a #1fa87a. Hay que medir los dos temas por separado.

  1. Recorrido de revisión de la interfaz terminada

Antes de conectar nada, se recorre la aplicación entera. Este es el guion, con lo que debe verse en cada parada:

# Ruta Qué debe verse
1 / Cabecera con cuatro enlaces, «Catálogo» activo con su subrayado. Resumen de flota con 3 disponibles, 1 alquilada y 1 en mantenimiento. Rejilla de cinco tarjetas: bici-002 y bici-003 con el botón apagado y su etiqueta de color
2 / + filtro «Eléctrica» Dos tarjetas. El botón queda marcado como activado
3 / + buscar «carga» Una tarjeta. Borrando el texto, vuelven las cinco
4 / + filtro «De carga» + buscar «urbana» Estado vacío con su explicación, no una rejilla en blanco
5 /bicicletas/bici-001 Ficha con dos paneles, migas «Inicio / Ficha de bicicleta», botón de reserva activo
6 /bicicletas/bici-003 Botón apagado con el motivo escrito debajo
7 /bicicletas/no-existe Mensaje propio de bicicleta inexistente, con enlace de vuelta
8 /estaciones Tres tarjetas con su recuento de bicicletas y disponibles
9 /estaciones/est-01 Pestaña «Flota» activa, dos bicicletas. Al pulsar «Incidencias», la URL cambia y el botón atrás vuelve
10 /reservas Tabla con una fila. Pulsar «Cancelar» abre el modal, Escape lo cierra y el foco vuelve al botón
11 /reservas/nueva Formulario con los cuatro campos etiquetados y el total recalculándose al cambiar las horas
12 /acceso Formulario centrado, campo con autoComplete
13 /taller Sección de mantenimiento primero, resto de la flota después
14 /ruta-inventada PaginaNoEncontrada con la cabecera y el pie en su sitio
15 Botón de tema Toda la aplicación cambia; al recargar, el tema se mantiene
16 Ventana a 375 px Una columna, navegación desplazable, sin desplazamiento horizontal en el cuerpo

Y las cinco comprobaciones transversales que cierran la revisión:

  • [ ] Con el teclado, de punta a punta: primer Tab en «Saltar al contenido», y desde ahí se llega a todo.
  • [ ] Sin desplazamiento horizontal a 320 px de ancho en ninguna pantalla.
  • [ ] Con el tema oscuro activado, ningún texto pierde contraste.
  • [ ] Con la consola abierta, ni un aviso de React (claves duplicadas, props desconocidas, anidamiento inválido).
  • [ ] npm run lint en verde, incluidas las reglas de jsx-a11y.

Es en este punto donde se enseña la aplicación a quien la va a usar. Los cambios que salgan de esa conversación —«el precio debería verse antes que la estación», «cancelar debería estar más escondido»— cuestan hoy diez minutos. Después de 11-03 costarían una tarde.

Errores Comunes y Consejos

  • Empezar por las pantallas y no por el sistema de diseño. Es la vía rápida a tener seis grises distintos y cuatro tamaños de botón. Media hora en index.css y en los cinco componentes base ahorra días de armonización posterior.
  • Diseñar solo el caso feliz. Si el estado vacío, el de carga y el de error no se maquetan a la vez que el contenido, no se maquetan nunca: aparecen en producción como un hueco en blanco o un texto suelto en la esquina.
  • <div onClick> en lugar de <button>. No recibe foco, no responde a Enter ni a espacio y no se anuncia como control. jsx-a11y lo caza, pero el hábito correcto es usar el elemento nativo y estilarlo.
  • outline: none para «que quede más limpio». Deja sin indicador de foco a quien navega con teclado. Si el anillo por defecto no encaja, se sustituye por otro con :focus-visible; nunca se elimina.
  • Fijar el nivel de encabezado dentro de un componente reutilizable. Un Panel que siempre pinta <h2> rompe la jerarquía en cuanto se anida. El nivel es una prop.
  • Media queries simuladas en JavaScript. useAnchoVentana para decidir el número de columnas produce parpadeo en la carga y repintados en cada resize. CSS lo hace mejor, antes y gratis.
  • Placeholder en lugar de <label>. El texto desaparece al escribir, muchos lectores de pantalla no lo anuncian como nombre y el contraste suele ser insuficiente. Campo fuerza la etiqueta precisamente por esto.
  • Botón deshabilitado sin explicación. «No disponible» sin decir por qué convierte una limitación comprensible en una frustración.
  • Consejo: revisa cada pantalla en escala de grises. Si dejas de distinguir el estado de una bicicleta o el enlace activo, el color está haciendo un trabajo que no puede hacer solo.
  • Consejo: deja los interruptores CARGANDO y CON_ERROR en el código mientras dure esta lección. Poner uno a true es la forma más rápida de revisar un estado que todavía no puede ocurrir, y en 11-03 se sustituyen por isPending e isError sin tocar el marcado.

Ejercicios

Ejercicio 1. ResumenFlota muestra el recuento de bicicletas por estado. Escríbelo completo: recibe bicicletas, calcula cuántas hay en cada estado, y las presenta con una barra proporcional. Requisitos: debe estar memorizado, la información no puede depender solo del color, el recuento debe ser legible para un lector de pantalla, y no puede romperse con una lista vacía. Indica también qué nivel de encabezado usarías y por qué.

Ejercicio 2. La revisión del apartado 19 devuelve tres problemas de la pantalla /reservas: (a) al cerrar el modal con Escape, el foco se pierde y la siguiente tabulación empieza por la cabecera; (b) en un móvil de 360 px, la tabla provoca desplazamiento horizontal en toda la página; (c) el estado de la reserva se distingue solo por el color de la etiqueta. Diagnostica y corrige cada uno indicando el fichero que tocarías.

Ejercicio 3. El equipo propone sustituir el SelectorTipo de cuatro botones por un <select> desplegable «porque ocupa menos». Evalúa la propuesta desde tres ángulos —usabilidad, accesibilidad y lo que 11-03 hará con el filtro— y da una recomendación razonada. Si la aceptas en algún caso, escribe el código de la variante y explica en qué condiciones se usaría cada una.

Soluciones

Solución 1.

// src/componentes/ResumenFlota.jsx
import { memo, useMemo } from 'react';
import estilos from './ResumenFlota.module.css';

const ESTADOS = [
  { clave: 'disponible', texto: 'Disponibles', simbolo: '●' },
  { clave: 'alquilada', texto: 'Alquiladas', simbolo: '▲' },
  { clave: 'mantenimiento', texto: 'En mantenimiento', simbolo: '■' }
];

function ResumenFlota({ bicicletas }) {
  const recuento = useMemo(() => {
    return bicicletas.reduce(
      (acumulado, bici) => ({ ...acumulado, [bici.estado]: (acumulado[bici.estado] ?? 0) + 1 }),
      { disponible: 0, alquilada: 0, mantenimiento: 0 }
    );
  }, [bicicletas]);

  const total = bicicletas.length;

  // Estado vacío: sin esto habría una división por cero en el porcentaje
  if (total === 0) {
    return (
      <section className={estilos.resumen} aria-labelledby="titulo-resumen">
        <h2 id="titulo-resumen" className={estilos.titulo}>Estado de la flota</h2>
        <p>No hay bicicletas registradas.</p>
      </section>
    );
  }

  return (
    <section className={estilos.resumen} aria-labelledby="titulo-resumen">
      <h2 id="titulo-resumen" className={estilos.titulo}>Estado de la flota</h2>

      <ul className={estilos.lista}>
        {ESTADOS.map(({ clave, texto, simbolo }) => {
          const cantidad = recuento[clave];
          const porcentaje = Math.round((cantidad / total) * 100);

          return (
            <li key={clave} className={estilos.item}>
              <span className={estilos.encabezado}>
                {/* Símbolo: segunda señal además del color */}
                <span className={estilos[clave]} aria-hidden="true">{simbolo}</span>
                <span className={estilos.texto}>{texto}</span>
                <span className={estilos.cantidad}>
                  {cantidad}
                  <span className="visualmente-oculto"> de {total} bicicletas</span>
                </span>
              </span>

              {/* La barra es puramente visual: el dato ya está en el texto */}
              <div className={estilos.pista} aria-hidden="true">
                <div
                  className={`${estilos.barra} ${estilos[clave]}`}
                  style={{ inlineSize: `${porcentaje}%` }}
                />
              </div>
            </li>
          );
        })}
      </ul>
    </section>
  );
}

export default memo(ResumenFlota);

Las decisiones y su justificación:

Requisito Cómo se cumple
Memorizado memo en la exportación y useMemo en el recuento. bicicletas llega como referencia estable desde el catálogo, así que memo funciona de verdad (08-02)
No solo color Cada estado lleva símbolo distinto (, , ) además de su color, y el nombre escrito
Legible por lector de pantalla El dato está en texto (3 de 5 bicicletas), la barra es aria-hidden y el número lleva su contexto en un texto oculto visualmente
Sin romperse con lista vacía Retorno anticipado con total === 0, que además evita NaN% en la barra
Nivel de encabezado h2: el h1 es «Catálogo de bicicletas» y este es una sección de primer nivel dentro de la pantalla. Además aria-labelledby conecta la <section> con su título, para que aparezca nombrada en la lista de regiones

Un detalle que suele pasarse por alto: el porcentaje se redondea para pintar, pero nunca sustituye al número. «60 %» no dice cuántas bicicletas son; «3 de 5» sí.

Solución 2.

(a) El foco perdido al cerrar el modal. El diagnóstico es que Modal guarda document.activeElement en el efecto que se ejecuta cuando abierto pasa a true, pero el componente ya está devolviendo null antes de eso si se monta cerrado… y sobre todo, el elemento que abrió el modal puede haberse desmontado. En PaginaReservas ocurre lo segundo de forma sutil: al cerrar, se llama a setReservaACancelar(null) y la fila se vuelve a renderizar, con lo que elementoPrevio.current puede apuntar a un nodo que ya no está en el documento. Enfocar un nodo desconectado es una operación silenciosa: no falla, simplemente no hace nada, y el foco cae al <body>.

La corrección, en src/componentes/base/Modal.jsx, con una comprobación de conexión y un destino de respaldo:

useEffect(() => {
  if (!abierto) return;

  elementoPrevio.current = document.activeElement;
  contenedor.current?.focus();

  const overflowPrevio = document.body.style.overflow;
  document.body.style.overflow = 'hidden';

  return () => {
    document.body.style.overflow = overflowPrevio;

    const previo = elementoPrevio.current;
    // Solo si sigue existiendo Y está conectado al documento
    if (previo instanceof HTMLElement && previo.isConnected) {
      previo.focus();
    } else {
      // Respaldo: el contenido principal, que es enfocable por tabIndex={-1}
      document.getElementById('contenido')?.focus();
    }
  };
}, [abierto]);

El respaldo al main no es un parche: es la conducta correcta cuando el origen del foco ha desaparecido, porque deja a la persona al principio del contenido y no en el limbo del <body>.

(b) Desplazamiento horizontal por la tabla. El error de diagnóstico habitual aquí es tocar el body. El problema no es la página: es que la tabla es más ancha que su contenedor y arrastra a todo lo demás. La regla es que el contenido ancho se desplaza dentro de su propia caja, nunca la página entera. En src/componentes/PanelReservas.module.css:

.contenedorTabla {
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
}

.tabla {
  width: 100%;
  min-width: 34rem;      /* por debajo, las columnas se apelmazan: mejor desplazar */
  border-collapse: collapse;
}

/* Y en móvil, columnas menos críticas fuera */
@media (max-width: 30rem) {
  .columnaHoras {
    display: none;
  }
}
// PanelReservas.jsx
<div className={estilos.contenedorTabla}>
  <table className={estilos.tabla}>…</table>
</div>

Ocultar la columna «Horas» en pantallas muy pequeñas es aceptable porque el dato sigue estando en el detalle de la reserva; si fuera la única forma de conocerlo, esconderlo sería perder información, no adaptarla.

(c) El estado solo por color. En src/componentes/base/Etiqueta.module.css y en el componente: la etiqueta ya lleva el texto («Disponible», «Alquilada»), así que si el problema persiste es porque en la tabla se redujo a un punto de color. La corrección es mantener siempre el texto y añadir una segunda señal de forma:

.etiqueta {
  display: inline-flex;
  align-items: center;
  gap: var(--espacio-xs);
  padding: 0.15rem var(--espacio-s);
  border-radius: 999px;
  font-size: var(--texto-s);
  font-weight: 600;
  border: 1px solid currentColor;   /* el borde da forma además de color */
}

.disponible { color: var(--color-marca); background: color-mix(in srgb, var(--color-marca) 12%, transparent); }
.alquilada { color: var(--color-alquilada); background: color-mix(in srgb, var(--color-alquilada) 12%, transparent); }
.mantenimiento { color: var(--color-mantenimiento); background: color-mix(in srgb, var(--color-mantenimiento) 12%, transparent); }

La prueba definitiva es la del apartado 18: poner la pantalla en escala de grises. Si con los colores anulados sigues distinguiendo los tres estados —porque el texto los nombra—, está bien resuelto.

Solución 3.

Recomendación: mantener el grupo de botones en el catálogo. El argumento tiene tres partes.

Usabilidad. El criterio establecido para elegir entre botones y desplegable es el número de opciones y la frecuencia de cambio:

Grupo de botones <select>
Opciones visibles sin interactuar Todas Solo la elegida
Clics para cambiar de filtro 1 2 (abrir y elegir)
Descubrimiento de lo que se puede filtrar Inmediato Hay que abrirlo
Espacio ocupado Más Menos
Recomendado a partir de ≤ 5-6 opciones > 6-7 opciones

Con cuatro opciones y siendo el control más usado de la pantalla más visitada, el desplegable empeora la tarea principal para ahorrar un espacio que en escritorio sobra.

Accesibilidad. Aquí no hay un ganador claro, y conviene ser honesto: un <select> nativo con su <label> es perfectamente accesible y en móvil abre el selector del sistema, que es una experiencia excelente. El grupo de botones también lo es si lleva role="group", aria-label y aria-pressed, como el del apartado 10. La diferencia real está en otro sitio: el desplegable anuncia solo la opción actual, mientras que los botones anuncian el estado de todas, lo que ayuda a construir el modelo mental de qué filtros existen.

Lo que hará 11-03 con el filtro. Este es el argumento que decide. El filtro se muda a la URL (?tipo=electrica) para ser compartible. Con botones, cada opción puede además renderizarse como un enlace real si algún día interesa —con su URL en la barra de estado y su «abrir en pestaña nueva»—; con un <select> eso es imposible sin JavaScript. El grupo de botones encaja mejor con la decisión A6 del acta.

Dónde sí lo aceptaría, y el código. En el formulario de nueva reserva y en cualquier pantalla estrecha con más de seis opciones. Y en el propio catálogo, si algún día los tipos pasan de cuatro a doce, la solución no es el desplegable sino un componente que se adapte:

// src/componentes/SelectorTipo.jsx — variante adaptable
function SelectorTipo({ tipoElegido, alCambiarTipo, variante = 'auto' }) {
  const [pendiente, iniciarTransicion] = useTransition();

  const cambiar = (valor) => iniciarTransicion(() => alCambiarTipo(valor));

  if (variante === 'desplegable' || TIPOS.length > 6) {
    return (
      <Campo etiqueta="Tipo de bicicleta">
        {(props) => (
          <select {...props} value={tipoElegido} onChange={(e) => cambiar(e.target.value)}>
            {TIPOS.map((tipo) => (
              <option key={tipo.valor} value={tipo.valor}>{tipo.texto}</option>
            ))}
          </select>
        )}
      </Campo>
    );
  }

  return (
    <div className={clases(estilos.grupo, pendiente && estilos.pendiente)}
         role="group" aria-label="Filtrar por tipo de bicicleta">
      {TIPOS.map((tipo) => (
        <button key={tipo.valor} type="button"
                className={clases(estilos.boton, tipoElegido === tipo.valor && estilos.activo)}
                aria-pressed={tipoElegido === tipo.valor}
                onClick={() => cambiar(tipo.valor)}>
          {tipo.texto}
        </button>
      ))}
    </div>
  );
}

La condición que hace válida esta variante es que las dos formas comparten exactamente el mismo contrato de props (tipoElegido y alCambiarTipo). Quien lo usa no se entera de cuál se pinta, y las pruebas de 11-04 pueden interactuar con la que corresponda sin que la página cambie una línea. Si la variante obligara a cambiar el contrato, serían dos componentes distintos y habría que elegir uno.

Conclusión

CicloUrbano ya existe. Se puede abrir, recorrer, enseñar y criticar, y todavía no ha hecho ni una petición: exactamente el punto que se buscaba.

Lo primero que queda montado es el sistema de diseño, y es lo que sostiene todo lo demás: un index.css con reinicio, una escala de espaciado cerrada, variables de color y forma, :focus-visible definido una sola vez para que ninguna parte de la aplicación pueda quedarse sin indicador de foco, y prefers-reduced-motion respetado desde el primer día. El tema oscuro se resuelve redefiniendo variables bajo :root[data-tema='oscuro'], sin tocar una regla de componente, con el recordatorio de que un tema oscuro no es un tema claro invertido: los colores de marca hay que volver a medirlos sobre el fondo nuevo.

Encima viven los cinco componentes base, cuyo valor está en que existan y se usen. Boton invierte el valor por defecto de type para eliminar de raíz los envíos accidentales y une el estado visual al funcional en cargando. Campo usa hijos como función para generar el id con useId y cablear htmlFor, aria-invalid y aria-describedby en un solo sitio, de forma que escribir un campo accesible sea más fácil que escribir uno que no lo sea. Panel recibe el nivel de encabezado como prop, porque un componente no puede saber a qué profundidad lo van a colocar. Etiqueta nombra el estado además de colorearlo. Y Modal cumple las cuatro obligaciones de un diálogo: portal, aria-modal, cierre con Escape y devolución del foco al origen.

El marco aporta el enlace de salto como primer elemento enfocable, los puntos de referencia bien separados, el main enfocable por programa, la navegación con NavLink y su end en el catálogo, el estado activo señalado con algo más que color, y unas migas de pan que leen el handle de cada ruta y marcan la actual con aria-current.

De las ocho pantallas, lo que hay que llevarse no es el marcado sino el método: cada una se ha escrito con sus cuatro estados —cargando con EsqueletoPagina marcado como aria-hidden y su role="status" paralelo, vacío con explicación y salida, error con mensaje comprensible y reintento, y con datos—; las tablas son tablas de verdad con scope y caption; los botones deshabilitados dicen por qué lo están; las pestañas de estación son rutas anidadas y no estado local, para que sean enlazables y sobrevivan al botón atrás; y el orden del contenido responde a lo que cada persona usuaria necesita ver primero, que es una decisión tomada en 11-01 y cobrada aquí.

En adaptabilidad, la regla queda fijada: las media queries van en el CSS porque se aplican en el primer pintado, no cuestan nada y funcionan aunque el JavaScript tarde; useAnchoVentana se reserva para cuando hay que renderizar estructuras distintas, no estilos distintos. Y grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)) sustituye a tres puntos de ruptura con una línea.

Cierra la lección la lista de comprobación de accesibilidad de ocho puntos que se aplica pantalla a pantalla, y el recorrido de revisión de dieciséis paradas que se hace antes de conectar nada: es el momento más barato de todo el proyecto para cambiar de opinión.

Ahora la aplicación es una maqueta preciosa que no hace nada. Los datos son un fichero, los filtros solo viven en memoria, el formulario no envía, la sesión no existe y RutaProtegida deja pasar a todo el mundo. Gestión del Estado e Integración de API le da vida: la capa de acceso a datos que no sabe nada de React, TanStack Query con sus claves jerárquicas y sus valores por defecto justificados, la mutación completa de crear una reserva con validación, invalidación y redirección, la actualización optimista de confirmar y cancelar con su reversión, Redux para sesión y catálogo, el filtro mudándose a la URL, las rutas protegidas conectadas de verdad y una tabla de decisión que responde a la pregunta más difícil de todas: cuando algo falla, quién lo muestra.

Curso de React

Módulo 1: Introducción a React

Módulo 2: Componentes de React

Módulo 3: Trabajando con Eventos

Módulo 4: Conceptos Avanzados de Componentes

Módulo 5: Hooks de React

Módulo 6: Enrutamiento en React

Módulo 7: Gestión del Estado

Módulo 8: Optimización del Rendimiento

Módulo 9: Pruebas en React

Módulo 10: Temas Avanzados

Módulo 11: Proyecto: Construyendo una Aplicación Completa

© Copyright 2026. Todos los derechos reservados