Navegación a la página de detalle
Introducción
Ya tenemos una página de detalle y funciona correctamente. Podemos volver atrás, podemos volver al inicio…
Pero desde el listado NO podemos entrar al detalle de ninguna oferta.
Cada JobCard muestra la información (título, empresa, ubicación), pero no hay ningún enlace hacia /jobs/:id.
En esta clase vamos a solucionar exactamente eso: hacer que cada tarjeta sea clickeable y lleve a su página de detalle.
El problema actual
Si revisas tu componente JobCard, probablemente se vea algo así:
export function JobCard({ job }) {
return (
<article className={styles.card}>
<h3 className={styles.title}>{job.title}</h3>
<p className={styles.company}>{job.company}</p>
<p className={styles.location}>{job.location}</p>
<div className={styles.tags}>
{job.tags?.map((tag) => (
<span key={tag} className={styles.tag}>
{tag}
</span>
))}
</div>
</article>
)
}
No hay ninguna forma de navegar al detalle. Es solo una tarjeta estática.
Solución 1: Envolver todo en un Link
La forma más simple es envolver toda la tarjeta en un componente Link:
import { Link } from 'react-router'
import styles from './JobCard.module.css'
export function JobCard({ job }) {
return (
<Link to={`/jobs/${job.id}`} className={styles.cardLink}>
<article className={styles.card}>
<h3 className={styles.title}>{job.title}</h3>
<p className={styles.company}>{job.company}</p>
<p className={styles.location}>{job.location}</p>
<div className={styles.tags}>
{job.tags?.map((tag) => (
<span key={tag} className={styles.tag}>
{tag}
</span>
))}
</div>
</article>
</Link>
)
}
Puntos clave
- Construimos la URL dinámicamente con el id de la oferta
- Envolvemos el
<article>completo: Toda la tarjeta es clickeable className={styles.cardLink}: Para aplicar estilos al enlace
Estilos necesarios
Para que funcione bien visualmente, necesitas algo como:
/* JobCard.module.css */
.cardLink {
text-decoration: none;
color: inherit;
display: block;
transition: transform 0.2s ease;
}
.cardLink:hover {
transform: translateY(-4px);
}
.cardLink:hover .card {
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
.card {
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
transition: box-shadow 0.2s ease;
}
Ventajas de este enfoque
- ✅ Simple y directo
- ✅ Toda la tarjeta es clickeable: Mejor UX
- ✅ Funciona con teclado: Puedes navegar con Tab y Enter
- ✅ Click derecho funciona: “Abrir en nueva pestaña” funciona correctamente
- ✅ Accesibilidad: Los screen readers entienden que es un enlace
Solución 2: Usar navegación programática con onClick
Otra opción es usar navegación programática con el hook useNavigate:
import { useNavigate } from 'react-router'
import styles from './JobCard.module.css'
export function JobCard({ job }) {
const navigate = useNavigate()
const handleClick = () => {
navigate(`/jobs/${job.id}`)
}
return (
<article className={styles.card} onClick={handleClick} style={{ cursor: 'pointer' }}>
<h3 className={styles.title}>{job.title}</h3>
<p className={styles.company}>{job.company}</p>
<p className={styles.location}>{job.location}</p>
<div className={styles.tags}>
{job.tags?.map((tag) => (
<span key={tag} className={styles.tag}>
{tag}
</span>
))}
</div>
</article>
)
}
Problemas de este enfoque
- ❌ No funciona con click derecho: No puedes “Abrir en nueva pestaña”
- ❌ No funciona con Ctrl/Cmd + Click: No abre en nueva pestaña
- ❌ No funciona con teclado: No puedes navegar con Tab
- ❌ Peor accesibilidad: Los screen readers no saben que es clickeable
- ❌ Peor SEO: Los crawlers no pueden seguir el enlace
Cuándo usar navegación programática
Usa navigate() cuando:
- La navegación ocurre después de una acción: Como enviar un formulario
- Necesitas hacer algo antes de navegar: Como guardar datos
- Es una consecuencia de otra acción: No es el propósito principal del elemento
No uses navigate() para enlaces simples. Usa <Link>.
Solución 3: Híbrido con botón adicional
A veces quieres que la tarjeta sea clickeable, pero también tener un botón específico:
import { Link } from 'react-router'
import styles from './JobCard.module.css'
export function JobCard({ job }) {
return (
<article className={styles.card}>
<Link to={`/jobs/${job.id}`} className={styles.cardContent}>
<h3 className={styles.title}>{job.title}</h3>
<p className={styles.company}>{job.company}</p>
<p className={styles.location}>{job.location}</p>
<div className={styles.tags}>
{job.tags?.map((tag) => (
<span key={tag} className={styles.tag}>
{tag}
</span>
))}
</div>
</Link>
<div className={styles.actions}>
<button
onClick={(e) => {
e.stopPropagation()
// Guardar como favorito
}}
className={styles.favoriteButton}
>
⭐ Guardar
</button>
<Link to={`/jobs/${job.id}`} className={styles.detailButton}>
Ver detalles →
</Link>
</div>
</article>
)
}
Nota importante sobre e.stopPropagation()
Si tienes botones dentro del enlace, necesitas e.stopPropagation() para evitar que el click en el botón también active el enlace.
Mejorando la accesibilidad
Para hacer tu tarjeta más accesible:
Opción 1: Link con aria-label descriptivo
<Link
to={`/jobs/${job.id}`}
className={styles.cardLink}
aria-label={`Ver detalles de ${job.title} en ${job.company}`}
>
{/* contenido de la tarjeta */}
</Link>
Opción 2: Agregar role=“button” y keyboard handlers
Si usas navegación programática (no recomendado, pero por si acaso):
<article
className={styles.card}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleClick()
}
}}
role="button"
tabIndex={0}
aria-label={`Ver detalles de ${job.title}`}
>
{/* contenido */}
</article>
Pero de verdad, usa <Link> en su lugar.
Indicadores visuales de que es clickeable
Cursor pointer
.cardLink {
cursor: pointer;
}
Hover effects
.cardLink:hover .card {
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
Focus visible para teclado
.cardLink:focus-visible {
outline: 2px solid #0066cc;
outline-offset: 4px;
border-radius: 8px;
}
Pasando estado en la navegación
A veces quieres pasar información adicional al navegar:
<Link to={`/jobs/${job.id}`} state={{ from: '/jobs', searchQuery: 'react' }}>
{/* contenido */}
</Link>
Y en la página de detalle puedes acceder a ese estado:
import { useLocation } from 'react-router'
export function JobDetail() {
const location = useLocation()
const { from, searchQuery } = location.state || {}
// Puedes usar esto para mostrar un botón "Volver a búsqueda"
// O para recordar filtros aplicados
}
Abriendo en nueva pestaña
Si quieres que algunas tarjetas se abran en nueva pestaña:
<Link to={`/jobs/${job.id}`} target="_blank" rel="noopener noreferrer">
{/* contenido */}
</Link>
Importante: rel=“noopener noreferrer”
noopener: Evita que la nueva pestaña pueda acceder awindow.openernoreferrer: No envía el headerReferer- Seguridad: Previene ataques de phishing y protege la privacidad
Componente completo recomendado
Juntando las mejores prácticas:
import { Link } from 'react-router'
import styles from './JobCard.module.css'
export function JobCard({ job }) {
return (
<Link
to={`/jobs/${job.id}`}
className={styles.cardLink}
aria-label={`Ver detalles de ${job.title} en ${job.company}`}
>
<article className={styles.card}>
<div className={styles.header}>
<h3 className={styles.title}>{job.title}</h3>
{job.isNew && <span className={styles.badge}>Nuevo</span>}
</div>
<div className={styles.meta}>
<p className={styles.company}>
<span className={styles.icon}>🏢</span>
{job.company}
</p>
<p className={styles.location}>
<span className={styles.icon}>📍</span>
{job.location}
</p>
</div>
{job.tags && job.tags.length > 0 && (
<div className={styles.tags}>
{job.tags.map((tag) => (
<span key={tag} className={styles.tag}>
{tag}
</span>
))}
</div>
)}
<div className={styles.footer}>
<span className={styles.salary}>{job.salary || 'Salario a convenir'}</span>
<span className={styles.viewMore}>Ver más →</span>
</div>
</article>
</Link>
)
}
CSS recomendado
/* JobCard.module.css */
.cardLink {
text-decoration: none;
color: inherit;
display: block;
}
.card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 1.5rem;
transition: all 0.2s ease;
}
.cardLink:hover .card {
border-color: #0066cc;
box-shadow: 0 8px 16px rgba(0, 102, 204, 0.1);
transform: translateY(-2px);
}
.cardLink:focus-visible {
outline: 2px solid #0066cc;
outline-offset: 4px;
border-radius: 12px;
}
.header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 0.75rem;
}
.title {
font-size: 1.25rem;
font-weight: 600;
color: #111827;
margin: 0;
}
.badge {
background: #10b981;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.meta {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
color: #6b7280;
font-size: 0.875rem;
}
.meta p {
margin: 0;
display: flex;
align-items: center;
gap: 0.25rem;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.tag {
background: #f3f4f6;
color: #374151;
padding: 0.25rem 0.75rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 500;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
.salary {
font-weight: 600;
color: #0066cc;
}
.viewMore {
color: #6b7280;
font-size: 0.875rem;
transition: color 0.2s;
}
.cardLink:hover .viewMore {
color: #0066cc;
}
Resumen
En esta clase hemos aprendido:
- ✅ Cómo hacer clickeable cada tarjeta del listado usando el componente
Link - ✅ Por qué usar
<Link>es mejor quenavigate()para enlaces simples - ✅ Cómo construir URLs dinámicas con template literals:
`/jobs/${job.id}` - ✅ Mejores prácticas de accesibilidad con
aria-labelyfocus-visible - ✅ Indicadores visuales de que algo es clickeable: hover, cursor, transiciones
- ✅ Cómo pasar estado en la navegación si lo necesitas
- ✅ Componente completo con todas las mejores prácticas aplicadas
Ahora tu listado de ofertas está completamente conectado con las páginas de detalle. Los usuarios pueden:
- Hacer click en cualquier parte de la tarjeta para ver más
- Usar Tab para navegar por teclado
- Hacer click derecho para abrir en nueva pestaña
- Usar Ctrl/Cmd + Click para abrir en background
Una experiencia de usuario completa y profesional.