feat: comprehensive accessibility improvements

- Add skip navigation link for keyboard users
- Implement proper ARIA labels and roles throughout interface
- Add semantic HTML structure with headings and landmarks
- Enhance form accessibility with help text and fieldsets
- Improve screen reader support with live regions
- Add proper focus management for sidebar toggle
- Include descriptive labels for all interactive elements
- Ensure WCAG compliance for navigation and forms
This commit is contained in:
2025-06-28 05:49:56 +02:00
parent c5a95edc91
commit 9eb86b0502
4 changed files with 187 additions and 121 deletions

View File

@ -65,7 +65,8 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
onNavigate={collapseSidebar} onNavigate={collapseSidebar}
/> />
<div <main
id="main-content"
className={`flex-1 overflow-auto transition-all duration-300 py-4 pr-4 className={`flex-1 overflow-auto transition-all duration-300 py-4 pr-4
${ ${
isSidebarExpanded isSidebarExpanded
@ -76,7 +77,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
> >
{/* <div className="w-full mx-auto">{children}</div> */} {/* <div className="w-full mx-auto">{children}</div> */}
<div className="max-w-7xl mx-auto">{children}</div> <div className="max-w-7xl mx-auto">{children}</div>
</div> </main>
</div> </div>
); );
} }

View File

@ -142,45 +142,55 @@ export default function SessionsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Page heading for screen readers */}
<h1 className="sr-only">Sessions Management</h1>
{/* Header */} {/* Header */}
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<MessageSquare className="h-6 w-6" /> <MessageSquare className="h-6 w-6" />
<CardTitle>Chat Sessions</CardTitle> <CardTitle as="h2">Chat Sessions</CardTitle>
</div> </div>
</CardHeader> </CardHeader>
</Card> </Card>
{/* Search Input */} {/* Search Input */}
<Card> <section aria-labelledby="search-heading">
<CardContent className="pt-6"> <h2 id="search-heading" className="sr-only">Search Sessions</h2>
<div className="relative"> <Card>
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <CardContent className="pt-6">
<Input <div className="relative">
placeholder="Search sessions (ID, category, initial message...)" <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" aria-hidden="true" />
value={searchTerm} <Input
onChange={(e) => setSearchTerm(e.target.value)} placeholder="Search sessions (ID, category, initial message...)"
className="pl-10" value={searchTerm}
/> onChange={(e) => setSearchTerm(e.target.value)}
</div> className="pl-10"
</CardContent> aria-label="Search sessions by ID, category, or message content"
</Card> />
</div>
</CardContent>
</Card>
</section>
{/* Filter and Sort Controls */} {/* Filter and Sort Controls */}
<Card> <section aria-labelledby="filters-heading">
<CardHeader> <Card>
<div className="flex items-center justify-between"> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center justify-between">
<Filter className="h-5 w-5" /> <div className="flex items-center gap-2">
<CardTitle className="text-lg">Filters & Sorting</CardTitle> <Filter className="h-5 w-5" aria-hidden="true" />
</div> <CardTitle as="h2" id="filters-heading" className="text-lg">Filters & Sorting</CardTitle>
<Button </div>
variant="ghost" <Button
size="sm" variant="ghost"
onClick={() => setFiltersExpanded(!filtersExpanded)} size="sm"
className="gap-2" onClick={() => setFiltersExpanded(!filtersExpanded)}
> className="gap-2"
aria-expanded={filtersExpanded}
aria-controls="filter-content"
>
{filtersExpanded ? ( {filtersExpanded ? (
<> <>
<ChevronUp className="h-4 w-4" /> <ChevronUp className="h-4 w-4" />
@ -196,107 +206,147 @@ export default function SessionsPage() {
</div> </div>
</CardHeader> </CardHeader>
{filtersExpanded && ( {filtersExpanded && (
<CardContent> <CardContent id="filter-content">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4"> <fieldset>
{/* Category Filter */} <legend className="sr-only">Session Filters and Sorting Options</legend>
<div className="space-y-2"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
<Label htmlFor="category-filter">Category</Label> {/* Category Filter */}
<select <div className="space-y-2">
id="category-filter" <Label htmlFor="category-filter">Category</Label>
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" <select
value={selectedCategory} id="category-filter"
onChange={(e) => setSelectedCategory(e.target.value)} className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
> value={selectedCategory}
<option value="">All Categories</option> onChange={(e) => setSelectedCategory(e.target.value)}
{filterOptions.categories.map((cat) => ( aria-describedby="category-help"
<option key={cat} value={cat}> >
{cat} <option value="">All Categories</option>
</option> {filterOptions.categories.map((cat) => (
))} <option key={cat} value={cat}>
</select> {cat}
</div> </option>
))}
</select>
<div id="category-help" className="sr-only">
Filter sessions by category type
</div>
</div>
{/* Language Filter */} {/* Language Filter */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="language-filter">Language</Label> <Label htmlFor="language-filter">Language</Label>
<select <select
id="language-filter" id="language-filter"
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
value={selectedLanguage} value={selectedLanguage}
onChange={(e) => setSelectedLanguage(e.target.value)} onChange={(e) => setSelectedLanguage(e.target.value)}
> aria-describedby="language-help"
<option value="">All Languages</option> >
{filterOptions.languages.map((lang) => ( <option value="">All Languages</option>
<option key={lang} value={lang}> {filterOptions.languages.map((lang) => (
{lang.toUpperCase()} <option key={lang} value={lang}>
</option> {lang.toUpperCase()}
))} </option>
</select> ))}
</div> </select>
<div id="language-help" className="sr-only">
Filter sessions by language
</div>
</div>
{/* Start Date Filter */} {/* Start Date Filter */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="start-date-filter">Start Date</Label> <Label htmlFor="start-date-filter">Start Date</Label>
<Input <Input
type="date" type="date"
id="start-date-filter" id="start-date-filter"
value={startDate} value={startDate}
onChange={(e) => setStartDate(e.target.value)} onChange={(e) => setStartDate(e.target.value)}
/> aria-describedby="start-date-help"
</div> />
<div id="start-date-help" className="sr-only">
Filter sessions from this date onwards
</div>
</div>
{/* End Date Filter */} {/* End Date Filter */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="end-date-filter">End Date</Label> <Label htmlFor="end-date-filter">End Date</Label>
<Input <Input
type="date" type="date"
id="end-date-filter" id="end-date-filter"
value={endDate} value={endDate}
onChange={(e) => setEndDate(e.target.value)} onChange={(e) => setEndDate(e.target.value)}
/> aria-describedby="end-date-help"
</div> />
<div id="end-date-help" className="sr-only">
Filter sessions up to this date
</div>
</div>
{/* Sort Key */} {/* Sort Key */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="sort-key">Sort By</Label> <Label htmlFor="sort-key">Sort By</Label>
<select <select
id="sort-key" id="sort-key"
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
value={sortKey} value={sortKey}
onChange={(e) => setSortKey(e.target.value)} onChange={(e) => setSortKey(e.target.value)}
> aria-describedby="sort-key-help"
<option value="startTime">Start Time</option> >
<option value="category">Category</option> <option value="startTime">Start Time</option>
<option value="language">Language</option> <option value="category">Category</option>
<option value="sentiment">Sentiment</option> <option value="language">Language</option>
<option value="messagesSent">Messages Sent</option> <option value="sentiment">Sentiment</option>
<option value="avgResponseTime">Avg. Response Time</option> <option value="messagesSent">Messages Sent</option>
</select> <option value="avgResponseTime">Avg. Response Time</option>
</div> </select>
<div id="sort-key-help" className="sr-only">
Choose field to sort sessions by
</div>
</div>
{/* Sort Order */} {/* Sort Order */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="sort-order">Order</Label> <Label htmlFor="sort-order">Order</Label>
<select <select
id="sort-order" id="sort-order"
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
value={sortOrder} value={sortOrder}
onChange={(e) => setSortOrder(e.target.value as "asc" | "desc")} onChange={(e) => setSortOrder(e.target.value as "asc" | "desc")}
> aria-describedby="sort-order-help"
<option value="desc">Descending</option> >
<option value="asc">Ascending</option> <option value="desc">Descending</option>
</select> <option value="asc">Ascending</option>
</div> </select>
</div> <div id="sort-order-help" className="sr-only">
Choose ascending or descending order
</div>
</div>
</div>
</fieldset>
</CardContent> </CardContent>
)} )}
</Card> </Card>
</section>
{/* Results section */}
<section aria-labelledby="results-heading">
<h2 id="results-heading" className="sr-only">Session Results</h2>
{/* Live region for screen reader announcements */}
<div role="status" aria-live="polite" className="sr-only">
{loading && "Loading sessions..."}
{error && `Error loading sessions: ${error}`}
{!loading && !error && sessions.length > 0 && `Found ${sessions.length} sessions`}
{!loading && !error && sessions.length === 0 && "No sessions found"}
</div>
{/* Loading State */} {/* Loading State */}
{loading && ( {loading && (
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-8 text-muted-foreground" aria-hidden="true">
Loading sessions... Loading sessions...
</div> </div>
</CardContent> </CardContent>
@ -307,7 +357,7 @@ export default function SessionsPage() {
{error && ( {error && (
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="text-center py-8 text-destructive"> <div className="text-center py-8 text-destructive" role="alert" aria-hidden="true">
Error: {error} Error: {error}
</div> </div>
</CardContent> </CardContent>
@ -423,6 +473,7 @@ export default function SessionsPage() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
</section>
</div> </div>
); );
} }

View File

@ -22,6 +22,13 @@ export default function RootLayout({ children }: { children: ReactNode }) {
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body className="bg-background text-foreground min-h-screen font-sans antialiased"> <body className="bg-background text-foreground min-h-screen font-sans antialiased">
{/* Skip navigation link for keyboard users */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded focus:outline-none focus:ring-2 focus:ring-ring"
>
Skip to main content
</a>
<Providers>{children}</Providers> <Providers>{children}</Providers>
<Toaster /> <Toaster />
</body> </body>

View File

@ -209,6 +209,7 @@ export default function Sidebar({
)} )}
<div <div
id="main-sidebar"
className={`fixed md:relative h-screen bg-card border-r border-border shadow-lg transition-all duration-300 className={`fixed md:relative h-screen bg-card border-r border-border shadow-lg transition-all duration-300
${ ${
isExpanded ? (isMobile ? "w-full sm:w-80" : "w-56") : "w-16" isExpanded ? (isMobile ? "w-full sm:w-80" : "w-56") : "w-16"
@ -224,7 +225,9 @@ export default function Sidebar({
onToggle(); onToggle();
}} }}
className="p-1.5 rounded-md hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary transition-colors group" className="p-1.5 rounded-md hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary transition-colors group"
title="Expand sidebar" aria-label="Expand sidebar"
aria-expanded={isExpanded}
aria-controls="main-sidebar"
> >
<MinimalToggleIcon isExpanded={isExpanded} /> <MinimalToggleIcon isExpanded={isExpanded} />
</button> </button>
@ -263,13 +266,17 @@ export default function Sidebar({
onToggle(); onToggle();
}} }}
className="p-1.5 rounded-md hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary transition-colors group" className="p-1.5 rounded-md hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary transition-colors group"
title="Collapse sidebar" aria-label="Collapse sidebar"
aria-expanded={isExpanded}
aria-controls="main-sidebar"
> >
<MinimalToggleIcon isExpanded={isExpanded} /> <MinimalToggleIcon isExpanded={isExpanded} />
</button> </button>
</div> </div>
)} )}
<nav <nav
role="navigation"
aria-label="Main navigation"
className={`flex-1 py-4 px-2 overflow-y-auto overflow-x-visible ${isExpanded ? "pt-12" : "pt-4"}`} className={`flex-1 py-4 px-2 overflow-y-auto overflow-x-visible ${isExpanded ? "pt-12" : "pt-4"}`}
> >
<NavItem <NavItem