From 1f25c48b5eec26e506d8200e81f2d852dbde6e32 Mon Sep 17 00:00:00 2001 From: ShimiManashirov <122288727+ShimiManashirov@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:43:59 +0300 Subject: [PATCH 1/4] feat(frontend): a11y improvements + shared UI primitives (#92) - Global :focus-visible ring in index.css for app-wide keyboard focus - New shared Button (variants/sizes/loading) and StatCard primitives - New CountUp spring-number component (respects reduced-motion) - clickableProps() a11y helper; applied to interactive divs (course rows, sidebar profile, roadmap toggle, roster select, shop cards) - aria-labels/aria-expanded on icon-only buttons (bell, pagination, collapse) - Adopt Button on EmptyState, Dashboard header, ManagedCourses CTAs Co-authored-by: Claude Opus 4.8 --- frontend/src/components/common/Button.jsx | 81 +++++++++++++++++++ frontend/src/components/common/CountUp.jsx | 32 ++++++++ frontend/src/components/common/EmptyState.jsx | 8 +- frontend/src/components/common/Pagination.jsx | 10 ++- frontend/src/components/common/StatCard.jsx | 30 +++++++ .../dashboard/CourseSidebarItem.jsx | 4 +- .../src/components/dashboard/RoadmapView.jsx | 4 +- frontend/src/components/index.jsx | 3 + .../src/components/layout/StudentLayout.jsx | 3 +- .../layout/StudentNotificationBell.jsx | 9 ++- frontend/src/index.css | 15 ++++ frontend/src/pages/ClassRoster.jsx | 4 +- frontend/src/pages/Dashboard.jsx | 34 +++----- frontend/src/pages/ManagedCourses.jsx | 26 +++--- frontend/src/pages/StudyShop.jsx | 5 +- frontend/src/utils/a11y.js | 23 ++++++ 16 files changed, 239 insertions(+), 52 deletions(-) create mode 100644 frontend/src/components/common/Button.jsx create mode 100644 frontend/src/components/common/CountUp.jsx create mode 100644 frontend/src/components/common/StatCard.jsx create mode 100644 frontend/src/utils/a11y.js diff --git a/frontend/src/components/common/Button.jsx b/frontend/src/components/common/Button.jsx new file mode 100644 index 0000000..58a22ec --- /dev/null +++ b/frontend/src/components/common/Button.jsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { Loader2 } from 'lucide-react'; + +/** + * Shared button primitive. + * + * Consolidates the gradient / ghost / danger button styles that were inlined + * across the app into one accessible, consistent component. + * + * Props: + * - variant primary | gradient | ghost | danger | subtle (default primary) + * - size sm | md | lg (default md) + * - loading shows a spinner and disables the button + * - icon lucide icon component rendered before the label + * - iconAfter lucide icon component rendered after the label + * - fullWidth stretches to 100% width + * - as render as a different element (e.g. 'a') — defaults to 'button' + * Any other props (onClick, type, disabled, aria-*, href…) are forwarded. + */ +const VARIANTS = { + primary: + 'bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-400 hover:to-amber-400 text-white shadow-lg shadow-orange-500/25 hover:shadow-orange-500/40 hover:-translate-y-0.5', + gradient: + 'btn-gradient text-white shadow-lg', + purple: + 'bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white shadow-[0_4px_14px_rgba(124,58,237,0.35)] hover:shadow-[0_6px_20px_rgba(124,58,237,0.5)] hover:-translate-y-0.5', + ghost: + 'bg-slate-100 dark:bg-white/10 hover:bg-slate-200 dark:hover:bg-white/20 text-slate-700 dark:text-white', + subtle: + 'bg-transparent hover:bg-slate-100 dark:hover:bg-white/10 text-slate-600 dark:text-white/70', + danger: + 'bg-gradient-to-r from-red-500 to-rose-500 hover:from-red-400 hover:to-rose-400 text-white shadow-lg shadow-red-500/25', +}; + +const SIZES = { + sm: 'px-3 py-1.5 text-xs gap-1.5 rounded-xl', + md: 'px-4 py-2.5 text-sm gap-2 rounded-2xl', + lg: 'px-6 py-3 text-base gap-2.5 rounded-2xl', +}; + +const ICON_SIZE = { sm: 14, md: 15, lg: 18 }; + +const Button = ({ + variant = 'primary', + size = 'md', + loading = false, + icon: Icon, + iconAfter: IconAfter, + fullWidth = false, + as: Component = 'button', + className = '', + disabled, + children, + ...props +}) => { + const isDisabled = disabled || loading; + const iconSize = ICON_SIZE[size] || ICON_SIZE.md; + + return ( + + {loading + ? + : Icon && } + {children} + {!loading && IconAfter && } + + ); +}; + +export default Button; diff --git a/frontend/src/components/common/CountUp.jsx b/frontend/src/components/common/CountUp.jsx new file mode 100644 index 0000000..a404b8f --- /dev/null +++ b/frontend/src/components/common/CountUp.jsx @@ -0,0 +1,32 @@ +import React, { useEffect } from 'react'; +import { motion, useSpring, useTransform } from 'framer-motion'; + +/** + * Animates a number from its previous value to the new one with a spring. + * Mirrors the inline pattern used for the shop coin counter so XP / level / + * stat numbers across the app share one consistent count-up feel. + * + * Honours prefers-reduced-motion: jumps straight to the value. + * + * Props: + * - value target number + * - format optional (n) => string formatter (default toLocaleString) + * - className forwarded to the span + */ +const prefersReduced = + typeof window !== 'undefined' && + window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + +const CountUp = ({ value = 0, format = (n) => Math.round(n).toLocaleString(), className }) => { + const spring = useSpring(value, { stiffness: 80, damping: 18 }); + const display = useTransform(spring, format); + + useEffect(() => { + if (prefersReduced) spring.jump(value); + else spring.set(value); + }, [value, spring]); + + return {display}; +}; + +export default CountUp; diff --git a/frontend/src/components/common/EmptyState.jsx b/frontend/src/components/common/EmptyState.jsx index ae17437..b457107 100644 --- a/frontend/src/components/common/EmptyState.jsx +++ b/frontend/src/components/common/EmptyState.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import Button from './Button'; const EmptyState = ({ icon: Icon, title, description, action }) => (
@@ -14,12 +15,9 @@ const EmptyState = ({ icon: Icon, title, description, action }) => ( )}
{action && ( - + )} ); diff --git a/frontend/src/components/common/Pagination.jsx b/frontend/src/components/common/Pagination.jsx index 89836ef..dea9647 100644 --- a/frontend/src/components/common/Pagination.jsx +++ b/frontend/src/components/common/Pagination.jsx @@ -2,25 +2,29 @@ import React from 'react'; import { ChevronLeft, ChevronRight } from 'lucide-react'; const Pagination = ({ page, pages, onPage }) => ( -
+
+ ); export default Pagination; diff --git a/frontend/src/components/common/StatCard.jsx b/frontend/src/components/common/StatCard.jsx new file mode 100644 index 0000000..9d35f57 --- /dev/null +++ b/frontend/src/components/common/StatCard.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { motion } from 'framer-motion'; + +/** + * Compact stat tile used on the dashboard and elsewhere. + * + * Props: + * - icon lucide icon component + * - value the number/string to highlight + * - label small uppercase caption + * - color tailwind bg-* class used for the subtle hover wash + * - glow className applied to the icon (color + drop-shadow glow) + */ +const StatCard = ({ icon: Icon, value, label, color = 'bg-orange-500', glow = '' }) => ( + +
+ {Icon && } + {value} + {label} + +); + +export default StatCard; diff --git a/frontend/src/components/dashboard/CourseSidebarItem.jsx b/frontend/src/components/dashboard/CourseSidebarItem.jsx index de18f81..24d3ee1 100644 --- a/frontend/src/components/dashboard/CourseSidebarItem.jsx +++ b/frontend/src/components/dashboard/CourseSidebarItem.jsx @@ -1,12 +1,14 @@ import React from 'react'; import { motion } from 'framer-motion'; import { ChevronRight, PlayCircle } from 'lucide-react'; +import { clickableProps } from '../../utils/a11y'; const CourseSidebarItem = ({ course, isSelected, onClick }) => { return ( { {/* ── Card column ── */}
handleToggleExpand(nodeId)} + {...clickableProps(() => handleToggleExpand(nodeId), `${isExpanded ? 'Collapse' : 'Expand'} ${node.title}`)} + aria-expanded={isExpanded} whileHover={{ scale: 1.01, y: -1, transition: { duration: 0.15 } }} whileTap={{ scale: 0.985 }} className={` diff --git a/frontend/src/components/index.jsx b/frontend/src/components/index.jsx index 9120345..d3dcf46 100644 --- a/frontend/src/components/index.jsx +++ b/frontend/src/components/index.jsx @@ -1,4 +1,7 @@ // Design system barrel — used by design-sync converter +export { default as Button } from './common/Button.jsx'; +export { default as StatCard } from './common/StatCard.jsx'; +export { default as CountUp } from './common/CountUp.jsx'; export { default as EmptyState } from './common/EmptyState.jsx'; export { default as LoadingSkeleton } from './common/LoadingSkeleton.jsx'; export { default as ToastManager } from './common/ToastManager.jsx'; diff --git a/frontend/src/components/layout/StudentLayout.jsx b/frontend/src/components/layout/StudentLayout.jsx index 39ec06f..f86d981 100644 --- a/frontend/src/components/layout/StudentLayout.jsx +++ b/frontend/src/components/layout/StudentLayout.jsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from 'react'; +import { clickableProps } from '../../utils/a11y'; import { Settings, LogOut, Menu, Map, BookOpen, ChevronRight, ChevronDown, X, Flame, Zap, User, ShoppingBag, Trophy } from 'lucide-react'; import { useNavigate, useLocation } from 'react-router-dom'; import { io } from 'socket.io-client'; @@ -256,7 +257,7 @@ const SidebarContent = ({ location, navigate, courses, selectedCourseId, setSele {/* Player Card with gradient border */}
{ navigate('/profile'); setIsSidebarOpen(false); }} + {...clickableProps(() => { navigate('/profile'); setIsSidebarOpen(false); }, 'Open my profile')} className="cursor-pointer group/card mb-3" > { return (
+ {/* ── Stats Strip ── */} @@ -284,9 +271,12 @@ const Dashboard = () => {
)} diff --git a/frontend/src/pages/ManagedCourses.jsx b/frontend/src/pages/ManagedCourses.jsx index 1c239a2..88a8d7f 100644 --- a/frontend/src/pages/ManagedCourses.jsx +++ b/frontend/src/pages/ManagedCourses.jsx @@ -4,10 +4,11 @@ import InstructorLayout from '../components/layout/InstructorLayout'; import useCourseStore from '../store/courseStore'; import useEnrollmentStore from '../store/enrollmentStore'; import { getDeptStyle } from '../utils/departmentStyles'; +import Button from '../components/common/Button'; import { Search, BookOpen, Users, ArrowRight, Loader2, Trash2, Zap, Star, Layers, Check, X, Clock, AlertCircle, - UserCheck, ChevronDown, Pencil, Save + UserCheck, ChevronDown, Pencil, Save, Plus } from 'lucide-react'; const COURSE_AVATARS = [ @@ -123,17 +124,10 @@ const EditCourseModal = ({ course, onClose, onSave }) => { {/* Footer */}
- - + +
@@ -390,12 +384,14 @@ const ManagedCourses = () => { className="w-full pl-11 pr-4 py-3 rounded-2xl border border-slate-200 dark:border-white/10 bg-white/70 dark:bg-black/30 backdrop-blur-md text-slate-800 dark:text-white placeholder-slate-400 dark:placeholder-white/40 focus:border-[#D97757] focus:bg-white dark:focus:bg-slate-900/60 focus:outline-none transition-all shadow-sm focus:shadow-md dark:shadow-inner" />
- + New Course + )} diff --git a/frontend/src/pages/StudyShop.jsx b/frontend/src/pages/StudyShop.jsx index 4d1957a..eca7b2c 100644 --- a/frontend/src/pages/StudyShop.jsx +++ b/frontend/src/pages/StudyShop.jsx @@ -7,6 +7,7 @@ import { Coins, ShoppingBag, Zap, HelpCircle, Clock, Star } from 'lucide-react'; import { motion, useSpring, useTransform, AnimatePresence } from 'framer-motion'; import ConfettiEffect from '../components/gamification/ConfettiEffect'; import ItemPreviewModal from '../components/shop/ItemPreviewModal'; +import { clickableProps } from '../utils/a11y'; // ── Sub-components ──────────────────────────────────────────────────────── @@ -36,7 +37,7 @@ const ItemCard = ({ item, category, isOwned, ownedCount, onPreview }) => { initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }} - onClick={() => onPreview(item, category)} + {...clickableProps(() => onPreview(item, category), `Preview ${item.name}`)} className="group relative bg-white dark:bg-white/[0.03] border border-slate-200/60 dark:border-white/[0.06] rounded-2xl p-4 flex items-center justify-between gap-4 cursor-pointer hover:border-indigo-400/40 dark:hover:border-indigo-500/30 transition-all hover:shadow-lg" style={{ '--accent': r.color }} > @@ -117,7 +118,7 @@ const FeaturedBanner = ({ item, coins, isOwned, onPreview }) => { animate={{ opacity: 1, y: 0 }} className="relative rounded-3xl overflow-hidden border cursor-pointer group" style={{ borderColor: `${r.color}33`, background: `linear-gradient(135deg, ${r.color}11 0%, transparent 60%)` }} - onClick={() => onPreview(item, item.category)} + {...clickableProps(() => onPreview(item, item.category), `Preview featured item ${item.name}`)} >
diff --git a/frontend/src/utils/a11y.js b/frontend/src/utils/a11y.js new file mode 100644 index 0000000..868e1bd --- /dev/null +++ b/frontend/src/utils/a11y.js @@ -0,0 +1,23 @@ +/** + * Accessibility helpers for non-native interactive elements. + * + * When a `
`/`` must stay a div (e.g. for layout animations) + * but behaves like a button, spread `clickableProps()` onto it so it is + * reachable and operable by keyboard and announced correctly to screen readers. + * + * + * + * Adds: role="button", tabIndex, and Enter/Space activation. + */ +export const clickableProps = (onClick, label) => ({ + role: 'button', + tabIndex: 0, + 'aria-label': label, + onClick, + onKeyDown: (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick?.(e); + } + }, +}); From 14d8d27ce65f28ae0d3186e458c168b662bb233b Mon Sep 17 00:00:00 2001 From: ShimiManashirov <122288727+ShimiManashirov@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:32:39 +0300 Subject: [PATCH 2/4] fix(frontend): replace alert() with toasts, add error/a11y improvements Replaces all browser alert() calls with the shared toast system, adds missing error feedback for silently-failing fetches, fixes accessible alt text and aria-labels across avatars/icon buttons/form fields, wires up unconnected form labels, fixes an enrollment lookup using loose equality, and flags Leaderboard's mock-data fallback as "Demo" so it's not mistaken for real data. Co-Authored-By: Claude --- .../src/components/common/GlobalSearch.jsx | 6 ++- frontend/src/components/common/Pagination.jsx | 4 +- .../components/course/AnnouncementModal.jsx | 8 ++++ .../src/components/course/ReviewModal.jsx | 3 +- .../components/gamification/Leaderboard.jsx | 15 ++++++- .../components/layout/InstructorLayout.jsx | 2 +- .../src/components/layout/SettingsModal.jsx | 2 + .../src/components/quiz/QuizEditorModal.jsx | 8 +++- .../src/components/wizard/StepCoreDetails.jsx | 13 +++++-- .../src/components/wizard/StepMaterials.jsx | 3 +- frontend/src/pages/ClassRoster.jsx | 4 +- frontend/src/pages/CourseWizard.jsx | 5 ++- frontend/src/pages/InstructorDashboard.jsx | 2 +- frontend/src/pages/LessonQuiz.jsx | 29 ++++++++++++-- frontend/src/pages/ManagedCourses.jsx | 8 +++- frontend/src/pages/MyCourses.jsx | 2 +- frontend/src/pages/MyEnrollments.jsx | 39 +++++++++++++------ frontend/src/pages/RoleSelectPage.jsx | 7 ++-- frontend/src/pages/StudentStatusOverview.jsx | 16 +++++--- frontend/src/pages/admin/InstructorsTab.jsx | 2 +- 20 files changed, 133 insertions(+), 45 deletions(-) diff --git a/frontend/src/components/common/GlobalSearch.jsx b/frontend/src/components/common/GlobalSearch.jsx index fc1c188..e51e542 100644 --- a/frontend/src/components/common/GlobalSearch.jsx +++ b/frontend/src/components/common/GlobalSearch.jsx @@ -71,6 +71,7 @@ const GlobalSearch = () => { setQuery(e.target.value)} onFocus={() => { if (results.length > 0) setIsOpen(true); }} @@ -78,7 +79,10 @@ const GlobalSearch = () => { className="flex-1 bg-transparent text-xs font-medium text-slate-700 dark:text-white placeholder-slate-400 dark:placeholder-white/25 focus:outline-none min-w-0" /> {query && ( - )} diff --git a/frontend/src/components/common/Pagination.jsx b/frontend/src/components/common/Pagination.jsx index dea9647..a7edcd6 100644 --- a/frontend/src/components/common/Pagination.jsx +++ b/frontend/src/components/common/Pagination.jsx @@ -8,7 +8,7 @@ const Pagination = ({ page, pages, onPage }) => ( onClick={() => onPage(page - 1)} disabled={page <= 1} aria-label="Previous page" - className="w-8 h-8 rounded-lg border border-slate-200/60 dark:border-white/10 flex items-center justify-center disabled:opacity-30 hover:bg-slate-100 dark:hover:bg-white/5 transition" + className="w-8 h-8 rounded-lg border border-slate-200/60 dark:border-white/10 flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent hover:bg-slate-100 dark:hover:bg-white/5 transition" > @@ -20,7 +20,7 @@ const Pagination = ({ page, pages, onPage }) => ( onClick={() => onPage(page + 1)} disabled={page >= pages} aria-label="Next page" - className="w-8 h-8 rounded-lg border border-slate-200/60 dark:border-white/10 flex items-center justify-center disabled:opacity-30 hover:bg-slate-100 dark:hover:bg-white/5 transition" + className="w-8 h-8 rounded-lg border border-slate-200/60 dark:border-white/10 flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent hover:bg-slate-100 dark:hover:bg-white/5 transition" > diff --git a/frontend/src/components/course/AnnouncementModal.jsx b/frontend/src/components/course/AnnouncementModal.jsx index 06a8de9..c0464ca 100644 --- a/frontend/src/components/course/AnnouncementModal.jsx +++ b/frontend/src/components/course/AnnouncementModal.jsx @@ -52,7 +52,9 @@ const AnnouncementModal = ({ courseId, onClose }) => { > {/* Compose */}
+ { maxLength={200} className="w-full px-4 py-2.5 rounded-2xl border border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5 text-sm font-semibold text-slate-800 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40" /> +