a11y: Enhance accessibility across various components by adding ARIA attributes, semantic elements, and input labels.

This commit is contained in:
2026-02-25 17:18:12 -03:00
parent f36c53aed1
commit f6c48ca8f0
9 changed files with 108 additions and 52 deletions
@@ -33,10 +33,12 @@ export const AnnouncementCard: React.FC<AnnouncementCardProps> = ({
};
return (
<div className={`relative p-6 rounded-2xl border transition-all duration-300 ${announcement.is_pinned
<article
aria-labelledby={`ann-title-${announcement.id}`}
className={`relative p-6 rounded-2xl border transition-all duration-300 ${announcement.is_pinned
? 'bg-primary-500/10 border-primary-500/30'
: 'bg-white/5 border-white/10 hover:border-white/20'
}`}>
}`}>
{announcement.is_pinned && (
<div className="absolute top-4 right-4 text-primary-400">
<Pin className="w-4 h-4 fill-current" />
@@ -67,17 +69,18 @@ export const AnnouncementCard: React.FC<AnnouncementCardProps> = ({
disabled={isDeleting}
className="p-2 rounded-lg hover:bg-red-500/20 text-gray-400 hover:text-red-400 transition-colors"
title="Eliminar anuncio"
aria-label="Eliminar anuncio"
>
<Trash2 className="w-4 h-4" />
<Trash2 className="w-4 h-4" aria-hidden="true" />
</button>
</div>
)}
</div>
<h3 className="text-xl font-bold text-white mb-2">{announcement.title}</h3>
<h3 id={`ann-title-${announcement.id}`} className="text-xl font-bold text-white mb-2">{announcement.title}</h3>
<div className="text-gray-300 whitespace-pre-wrap leading-relaxed">
{announcement.content}
</div>
</div>
</article>
);
};
+17 -8
View File
@@ -25,14 +25,14 @@ export default function AppHeader() {
<Link href="/" className="flex items-center gap-2 md:gap-3 group" aria-label={`${platformName} - Dashboard`}>
<div className="w-8 h-8 md:w-10 md:h-10 rounded-lg md:rounded-xl bg-blue-600 flex items-center justify-center font-black text-white shadow-lg shadow-blue-500/20 group-hover:scale-105 transition-all overflow-hidden relative">
{branding?.logo_url ? (
<Image src={getImageUrl(branding.logo_url)} alt={branding.name} fill className="object-contain" sizes="40px" />
<Image src={getImageUrl(branding.logo_url)} alt="" fill className="object-contain" sizes="40px" />
) : (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-blue-500 to-blue-700" aria-hidden="true">
{platformName.charAt(0).toUpperCase()}
</div>
)}
</div>
<div className="flex flex-col -gap-1">
<div className="flex flex-col -gap-1" aria-hidden="true">
<span className="font-black text-sm md:text-lg tracking-tighter text-white leading-none">
{platformName.toUpperCase()}
</span>
@@ -41,7 +41,7 @@ export default function AppHeader() {
</Link>
<div className="flex items-center gap-4">
<nav className="hidden md:flex items-center gap-8 mr-4">
<nav className="hidden md:flex items-center gap-8 mr-4" aria-label="Navegación principal">
<Link href="/" className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400 hover:text-white transition-colors">
{t('nav.catalog')}
</Link>
@@ -106,8 +106,17 @@ export default function AppHeader() {
{/* Mobile Sidebar Overlay */}
{isMenuOpen && (
<div className="fixed inset-0 z-[150] md:hidden bg-black/60 backdrop-blur-sm animate-in fade-in duration-300">
<div className="absolute right-0 top-0 bottom-0 w-64 glass border-l border-white/10 p-6 flex flex-col animate-in slide-in-from-right duration-300">
<div
className="fixed inset-0 z-[150] md:hidden bg-black/60 backdrop-blur-sm animate-in fade-in duration-300"
onClick={() => setIsMenuOpen(false)}
>
<div
className="absolute right-0 top-0 bottom-0 w-64 glass border-l border-white/10 p-6 flex flex-col animate-in slide-in-from-right duration-300"
role="dialog"
aria-modal="true"
aria-label="Menú móvil"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-center mb-8">
<span className="font-black text-xs uppercase tracking-[0.2em] text-gray-500">Menú</span>
<button
@@ -119,7 +128,7 @@ export default function AppHeader() {
</button>
</div>
<nav className="flex flex-col gap-6 flex-1">
<nav className="flex flex-col gap-6 flex-1" aria-label="Mobile navigation">
<Link
href="/"
onClick={() => setIsMenuOpen(false)}
@@ -175,7 +184,7 @@ export default function AppHeader() {
onClick={() => setIsMenuOpen(false)}
className="flex items-center gap-3 px-4 py-3 rounded-xl hover:bg-white/5 transition-colors"
>
<div className="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center font-bold text-xs text-blue-400">
<div className="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center font-bold text-xs text-blue-400" aria-hidden="true">
{user?.full_name?.charAt(0) || 'U'}
</div>
<span className="text-sm font-bold">{user?.full_name || 'Mi Perfil'}</span>
@@ -184,7 +193,7 @@ export default function AppHeader() {
onClick={logout}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-red-400 hover:bg-red-500/10 transition-colors"
>
<LogOut size={18} />
<LogOut size={18} aria-hidden="true" />
<span className="text-sm font-bold">{t('nav.signOut')}</span>
</button>
</div>
@@ -56,12 +56,14 @@ export default function InteractiveTranscript({ transcription, currentTime, onSe
<div className="flex bg-black/40 rounded-lg p-1 border border-white/5">
<button
onClick={() => setLang('en')}
aria-pressed={lang === 'en'}
className={`px-3 py-1 text-[10px] font-black rounded-md transition-all ${lang === 'en' ? 'bg-blue-600 text-white' : 'text-gray-500 hover:text-white'}`}
>
EN
</button>
<button
onClick={() => setLang('es')}
aria-pressed={lang === 'es'}
className={`px-3 py-1 text-[10px] font-black rounded-md transition-all ${lang === 'es' ? 'bg-blue-600 text-white' : 'text-gray-500 hover:text-white'}`}
>
ES
@@ -71,6 +73,9 @@ export default function InteractiveTranscript({ transcription, currentTime, onSe
<div
ref={scrollRef}
role="region"
aria-label="Contenido de la transcripción"
aria-live="polite"
className="flex-1 overflow-y-auto p-6 space-y-4 custom-scrollbar"
>
{cues.length === 0 ? (
@@ -88,14 +93,23 @@ export default function InteractiveTranscript({ transcription, currentTime, onSe
<div
key={index}
ref={active ? activeCueRef : null}
role="button"
tabIndex={0}
aria-label={`${formatTime(cue.start)}: ${cue.text}`}
aria-current={active ? "true" : undefined}
onClick={() => onSeek(cue.start)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onSeek(cue.start);
}
}}
className={`group cursor-pointer p-4 rounded-2xl transition-all border ${active
? 'bg-blue-500/10 border-blue-500/30 text-white translate-x-1'
: 'bg-white/5 border-transparent text-gray-400 hover:bg-white/10 hover:border-white/10'
}`}
>
<div className="flex items-start gap-4">
<span className={`text-[10px] font-mono mt-1 ${active ? 'text-blue-400' : 'text-gray-600'}`}>
<span className={`text-[10px] font-mono mt-1 ${active ? 'text-blue-400' : 'text-gray-600'}`} aria-hidden="true">
{formatTime(cue.start)}
</span>
<p className={`text-sm leading-relaxed ${active ? 'font-medium' : ''}`}>
+12 -12
View File
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { lmsApi, User } from "@/lib/api";
import { Trophy, Medal, Award } from "lucide-react";
import { Trophy, Medal } from "lucide-react";
export default function Leaderboard() {
const [topUsers, setTopUsers] = useState<User[]>([]);
@@ -33,21 +33,21 @@ export default function Leaderboard() {
return (
<div className="glass-card p-8 border-white/5 bg-white/[0.01] rounded-3xl overflow-hidden relative">
<h3 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-6 flex items-center gap-2">
<Trophy size={14} className="text-amber-500" /> Tabla de Clasificación
<Trophy size={14} className="text-amber-500" aria-hidden="true" /> Tabla de Clasificación
</h3>
<div className="space-y-3">
<ol className="space-y-3" aria-label="Ranking de estudiantes">
{topUsers.map((user, index) => (
<div
<li
key={user.id}
className={`flex items-center gap-4 p-4 rounded-2xl transition-all ${index === 0 ? 'bg-gradient-to-r from-amber-500/10 to-transparent border border-amber-500/20 shadow-lg shadow-amber-500/5' :
'bg-white/5 border border-white/5 hover:bg-white/10'
'bg-white/5 border border-white/5 hover:bg-white/10'
}`}
>
<div className="flex-shrink-0 w-8 text-center font-black text-xs text-gray-600">
{index === 0 ? <Medal className="text-amber-500 mx-auto" size={18} /> :
index === 1 ? <Medal className="text-gray-400 mx-auto" size={18} /> :
index === 2 ? <Medal className="text-amber-700 mx-auto" size={18} /> :
<div className="flex-shrink-0 w-8 text-center font-black text-xs text-gray-600" aria-label={`Posición ${index + 1}`}>
{index === 0 ? <Medal className="text-amber-500 mx-auto" size={18} aria-hidden="true" /> :
index === 1 ? <Medal className="text-gray-400 mx-auto" size={18} aria-hidden="true" /> :
index === 2 ? <Medal className="text-amber-700 mx-auto" size={18} aria-hidden="true" /> :
index + 1}
</div>
@@ -60,7 +60,7 @@ export default function Leaderboard() {
<div className="text-sm font-black text-white">{user.xp || 0}</div>
<div className="text-[8px] font-black text-blue-500 uppercase tracking-widest">XP</div>
</div>
</div>
</li>
))}
{topUsers.length === 0 && (
@@ -68,10 +68,10 @@ export default function Leaderboard() {
Aún no hay datos de clasificación disponibles.
</div>
)}
</div>
</ol>
{/* Visual background flair */}
<div className="absolute -top-24 -right-24 w-48 h-48 bg-amber-500/10 blur-[100px] rounded-full pointer-events-none"></div>
<div className="absolute -top-24 -right-24 w-48 h-48 bg-amber-500/10 blur-[100px] rounded-full pointer-events-none" aria-hidden="true"></div>
</div>
);
}
@@ -41,11 +41,18 @@ export const NewAnnouncementModal: React.FC<NewAnnouncementModalProps> = ({
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="bg-[#1a1c1e] border border-white/10 rounded-3xl w-full max-w-2xl overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200">
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={onClose}>
<div
className="bg-[#1a1c1e] border border-white/10 rounded-3xl w-full max-w-2xl overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
role="dialog"
aria-modal="true"
aria-labelledby="modal-announcement-title"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-6 border-b border-white/5">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<Send className="w-5 h-5 text-primary-500" />
<h2 id="modal-announcement-title" className="text-xl font-bold text-white flex items-center gap-2">
<Send className="w-5 h-5 text-primary-500" aria-hidden="true" />
Publicar Nuevo Anuncio
</h2>
<button onClick={onClose} className="p-2 hover:bg-white/5 rounded-xl text-gray-400 transition-colors">
@@ -62,20 +69,23 @@ export const NewAnnouncementModal: React.FC<NewAnnouncementModalProps> = ({
)}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-400 ml-1">Título del Anuncio</label>
<label htmlFor="ann-title" className="text-sm font-medium text-gray-400 ml-1">Título del Anuncio</label>
<input
id="ann-title"
type="text"
required
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Ej: Nuevos materiales disponibles"
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:border-primary-500/50 transition-colors"
autoFocus
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-400 ml-1">Contenido</label>
<label htmlFor="ann-content" className="text-sm font-medium text-gray-400 ml-1">Contenido</label>
<textarea
id="ann-content"
required
rows={6}
value={content}
@@ -46,10 +46,15 @@ export default function NewThreadModal({ courseId, lessonId, onClose, onSuccess
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 z-50">
<div className="bg-slate-900 border border-white/10 rounded-3xl max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-2xl">
<div
className="bg-slate-900 border border-white/10 rounded-3xl max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="modal-thread-title"
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-white/10">
<h2 className="text-2xl font-black text-white">Nuevo Hilo de Discusión</h2>
<h2 id="modal-thread-title" className="text-2xl font-black text-white">Nuevo Hilo de Discusión</h2>
<button
onClick={onClose}
className="w-10 h-10 rounded-full bg-white/5 hover:bg-white/10 flex items-center justify-center text-gray-400 hover:text-white transition-all"
@@ -68,10 +73,11 @@ export default function NewThreadModal({ courseId, lessonId, onClose, onSuccess
{/* Title */}
<div className="space-y-2">
<label className="text-xs font-bold text-gray-400 uppercase tracking-wider">
<label htmlFor="thread-title" className="text-xs font-bold text-gray-400 uppercase tracking-wider">
Título
</label>
<input
id="thread-title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
@@ -79,19 +85,21 @@ export default function NewThreadModal({ courseId, lessonId, onClose, onSuccess
className="w-full bg-slate-900/50 border border-white/10 rounded-xl py-3 px-4 text-white text-sm focus:border-indigo-500 focus:outline-none transition-colors"
disabled={submitting}
maxLength={200}
autoFocus
/>
<p className="text-xs text-gray-500">{title.length}/200 caracteres</p>
<p id="thread-title-hint" className="text-xs text-gray-500">{title.length}/200 caracteres</p>
</div>
{/* Content */}
<div className="space-y-2">
<label className="text-xs font-bold text-gray-400 uppercase tracking-wider">
<label htmlFor="thread-content" className="text-xs font-bold text-gray-400 uppercase tracking-wider">
Contenido
</label>
<textarea
id="thread-content"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Describe tu pregunta o tema en detalle..."
placeholder="Describe tu pregunta o tema en detail..."
className="w-full bg-slate-900/50 border border-white/10 rounded-xl py-3 px-4 text-white text-sm focus:border-indigo-500 focus:outline-none transition-colors resize-none"
rows={8}
disabled={submitting}
@@ -149,6 +149,7 @@ export default function NotificationCenter() {
<div className="p-4 border-t border-white/5 bg-white/5 text-center">
<button
className="text-[10px] font-black uppercase tracking-widest text-gray-500 hover:text-white transition-colors"
aria-label="Marcar todas las notificaciones como leídas"
onClick={() => {
notifications.filter(n => !n.is_read).forEach(n => markAsRead(n.id));
}}
+20 -12
View File
@@ -32,14 +32,17 @@ export default function PostCard({ post, onReply, onVote, onEndorse, depth, isIn
const indentClass = depth > 0 ? `ml-${Math.min(depth * 4, 16)} pl-4 border-l-2 border-white/10` : '';
return (
<div className={`${indentClass} ${depth > 0 ? 'mt-4' : 'mt-6'}`}>
<article
className={`${indentClass} ${depth > 0 ? 'mt-4' : 'mt-6'}`}
aria-labelledby={`post-content-${post.id}`}
>
<div className="bg-white/5 backdrop-blur-xl border border-white/10 rounded-2xl p-4 hover:bg-white/[0.07] transition-all">
{/* Header */}
<div className="flex items-start gap-3 mb-3">
{/* Avatar */}
<div className="w-10 h-10 rounded-full bg-indigo-600/20 flex items-center justify-center text-indigo-400 flex-shrink-0">
<div className="w-10 h-10 rounded-full bg-indigo-600/20 flex items-center justify-center text-indigo-400 flex-shrink-0" aria-hidden="true">
{post.author_avatar ? (
<img src={post.author_avatar} alt={post.author_name} className="w-full h-full rounded-full object-cover" />
<img src={post.author_avatar} alt="" className="w-full h-full rounded-full object-cover" />
) : (
<User size={20} />
)}
@@ -63,7 +66,7 @@ export default function PostCard({ post, onReply, onVote, onEndorse, depth, isIn
</div>
{/* Content */}
<div className="text-gray-300 text-sm mb-4 whitespace-pre-wrap break-words">
<div id={`post-content-${post.id}`} className="text-gray-300 text-sm mb-4 whitespace-pre-wrap break-words">
{post.content}
</div>
@@ -73,12 +76,14 @@ export default function PostCard({ post, onReply, onVote, onEndorse, depth, isIn
<button
onClick={() => handleVote('upvote')}
disabled={isVoting}
aria-label={`Votar positivo (${post.upvotes} votos)`}
aria-pressed={post.user_vote === 'upvote'}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg transition-all ${post.user_vote === 'upvote'
? 'bg-indigo-600/30 text-indigo-400 border border-indigo-500/50'
: 'bg-white/5 text-gray-400 hover:bg-white/10 hover:text-white border border-white/10'
? 'bg-indigo-600/30 text-indigo-400 border border-indigo-500/50'
: 'bg-white/5 text-gray-400 hover:bg-white/10 hover:text-white border border-white/10'
} disabled:opacity-50`}
>
<ThumbsUp size={14} />
<ThumbsUp size={14} aria-hidden="true" />
<span className="font-bold">{post.upvotes}</span>
</button>
@@ -86,9 +91,10 @@ export default function PostCard({ post, onReply, onVote, onEndorse, depth, isIn
{depth < maxDepth && (
<button
onClick={() => onReply(post.id)}
aria-label={`Responder al post de ${post.author_name}`}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-white/5 text-gray-400 hover:bg-white/10 hover:text-white border border-white/10 transition-all"
>
<MessageSquare size={14} />
<MessageSquare size={14} aria-hidden="true" />
<span className="font-bold">Responder</span>
</button>
)}
@@ -97,12 +103,14 @@ export default function PostCard({ post, onReply, onVote, onEndorse, depth, isIn
{isInstructor && onEndorse && (
<button
onClick={() => onEndorse(post.id)}
aria-label={post.is_endorsed ? 'Quitar aprobación de respuesta' : 'Marcar como respuesta correcta'}
aria-pressed={!!post.is_endorsed}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg transition-all ${post.is_endorsed
? 'bg-green-600/30 text-green-400 border border-green-500/50'
: 'bg-white/5 text-gray-400 hover:bg-green-600/20 hover:text-green-400 border border-white/10'
? 'bg-green-600/30 text-green-400 border border-green-500/50'
: 'bg-white/5 text-gray-400 hover:bg-green-600/20 hover:text-green-400 border border-white/10'
}`}
>
<CheckCircle2 size={14} />
<CheckCircle2 size={14} aria-hidden="true" />
<span className="font-bold text-xs">{post.is_endorsed ? 'Aprobada' : 'Aprobar'}</span>
</button>
)}
@@ -125,6 +133,6 @@ export default function PostCard({ post, onReply, onVote, onEndorse, depth, isIn
))}
</div>
)}
</div>
</article>
);
}