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,44 +142,54 @@ 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 */}
<section aria-labelledby="search-heading">
<h2 id="search-heading" className="sr-only">Search Sessions</h2>
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" aria-hidden="true" />
<Input <Input
placeholder="Search sessions (ID, category, initial message...)" placeholder="Search sessions (ID, category, initial message...)"
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10" className="pl-10"
aria-label="Search sessions by ID, category, or message content"
/> />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</section>
{/* Filter and Sort Controls */} {/* Filter and Sort Controls */}
<section aria-labelledby="filters-heading">
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Filter className="h-5 w-5" /> <Filter className="h-5 w-5" aria-hidden="true" />
<CardTitle className="text-lg">Filters & Sorting</CardTitle> <CardTitle as="h2" id="filters-heading" className="text-lg">Filters & Sorting</CardTitle>
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setFiltersExpanded(!filtersExpanded)} onClick={() => setFiltersExpanded(!filtersExpanded)}
className="gap-2" className="gap-2"
aria-expanded={filtersExpanded}
aria-controls="filter-content"
> >
{filtersExpanded ? ( {filtersExpanded ? (
<> <>
@ -196,7 +206,9 @@ export default function SessionsPage() {
</div> </div>
</CardHeader> </CardHeader>
{filtersExpanded && ( {filtersExpanded && (
<CardContent> <CardContent id="filter-content">
<fieldset>
<legend className="sr-only">Session Filters and Sorting Options</legend>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
{/* Category Filter */} {/* Category Filter */}
<div className="space-y-2"> <div className="space-y-2">
@ -206,6 +218,7 @@ export default function SessionsPage() {
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={selectedCategory} value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)} onChange={(e) => setSelectedCategory(e.target.value)}
aria-describedby="category-help"
> >
<option value="">All Categories</option> <option value="">All Categories</option>
{filterOptions.categories.map((cat) => ( {filterOptions.categories.map((cat) => (
@ -214,6 +227,9 @@ export default function SessionsPage() {
</option> </option>
))} ))}
</select> </select>
<div id="category-help" className="sr-only">
Filter sessions by category type
</div>
</div> </div>
{/* Language Filter */} {/* Language Filter */}
@ -224,6 +240,7 @@ export default function SessionsPage() {
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> <option value="">All Languages</option>
{filterOptions.languages.map((lang) => ( {filterOptions.languages.map((lang) => (
@ -232,6 +249,9 @@ export default function SessionsPage() {
</option> </option>
))} ))}
</select> </select>
<div id="language-help" className="sr-only">
Filter sessions by language
</div>
</div> </div>
{/* Start Date Filter */} {/* Start Date Filter */}
@ -242,7 +262,11 @@ export default function SessionsPage() {
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 id="start-date-help" className="sr-only">
Filter sessions from this date onwards
</div>
</div> </div>
{/* End Date Filter */} {/* End Date Filter */}
@ -253,7 +277,11 @@ export default function SessionsPage() {
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 id="end-date-help" className="sr-only">
Filter sessions up to this date
</div>
</div> </div>
{/* Sort Key */} {/* Sort Key */}
@ -264,6 +292,7 @@ export default function SessionsPage() {
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="startTime">Start Time</option>
<option value="category">Category</option> <option value="category">Category</option>
@ -272,6 +301,9 @@ export default function SessionsPage() {
<option value="messagesSent">Messages Sent</option> <option value="messagesSent">Messages Sent</option>
<option value="avgResponseTime">Avg. Response Time</option> <option value="avgResponseTime">Avg. Response Time</option>
</select> </select>
<div id="sort-key-help" className="sr-only">
Choose field to sort sessions by
</div>
</div> </div>
{/* Sort Order */} {/* Sort Order */}
@ -282,21 +314,39 @@ export default function SessionsPage() {
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="desc">Descending</option>
<option value="asc">Ascending</option> <option value="asc">Ascending</option>
</select> </select>
<div id="sort-order-help" className="sr-only">
Choose ascending or descending order
</div> </div>
</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