feat: enhance accessibility across the application by improving keyboard navigation and screen reader support for various components

This commit is contained in:
Usman Baig
2026-02-22 20:39:18 +01:00
parent 06f54176f1
commit 8007900940
8 changed files with 81 additions and 25 deletions

View File

@@ -27,6 +27,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- **Organization context switch.** Switching away from a deleted organization now stores the session correctly instead of using an insecure fallback.
- **Removed debug logs.** Auth and organization-switching details no longer leak into the browser console in production.
- **Stricter type safety.** Eliminated all `any` types and `@ts-ignore` suppressions across the codebase, so the TypeScript compiler catches more bugs at build time.
- **Accessibility improvements.** The notification bell, workspace switcher, and all dashboard tabs are now fully keyboard-navigable. Screen readers announce unread counts, active organizations, and tab changes correctly. Decorative icons are hidden from assistive technology.
## [0.10.0-alpha] - 2026-02-21

View File

@@ -43,8 +43,8 @@ export default function OrganizationSwitcher({ orgs, activeOrgId }: { orgs: Orga
}
return (
<div className="border-b border-neutral-100 dark:border-neutral-800 pb-2 mb-2">
<div className="px-3 py-2 text-xs font-medium text-neutral-500 uppercase tracking-wider">
<div className="border-b border-neutral-100 dark:border-neutral-800 pb-2 mb-2" role="group" aria-label="Organizations">
<div className="px-3 py-2 text-xs font-medium text-neutral-500 uppercase tracking-wider" aria-hidden="true">
Organizations
</div>
@@ -74,21 +74,28 @@ export default function OrganizationSwitcher({ orgs, activeOrgId }: { orgs: Orga
<button
key={org.organization_id}
onClick={() => handleSwitch(org.organization_id)}
aria-current={activeOrgId === org.organization_id ? 'true' : undefined}
aria-busy={switching === org.organization_id ? 'true' : undefined}
className={`w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg transition-colors mt-1 ${
activeOrgId === org.organization_id ? 'bg-neutral-100 dark:bg-neutral-800' : 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50'
}`}
>
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<CubeIcon className="h-3 w-3 text-blue-600 dark:text-blue-400" />
<CubeIcon className="h-3 w-3 text-blue-600 dark:text-blue-400" aria-hidden="true" />
</div>
<span className="text-neutral-700 dark:text-neutral-300 truncate max-w-[140px]">
{org.organization_name}
</span>
</div>
<div className="flex items-center gap-2">
{switching === org.organization_id && <span className="text-xs text-neutral-400">Loading...</span>}
{activeOrgId === org.organization_id && !switching && <CheckIcon className="h-4 w-4 text-neutral-600 dark:text-neutral-400" />}
{switching === org.organization_id && <span className="text-xs text-neutral-400" aria-live="polite">Switching</span>}
{activeOrgId === org.organization_id && !switching && (
<>
<CheckIcon className="h-4 w-4 text-neutral-600 dark:text-neutral-400" aria-hidden="true" />
<span className="sr-only">(current)</span>
</>
)}
</div>
</button>
))}
@@ -98,7 +105,7 @@ export default function OrganizationSwitcher({ orgs, activeOrgId }: { orgs: Orga
href="/onboarding"
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-neutral-500 hover:text-blue-600 dark:text-neutral-400 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/10 rounded-lg transition-colors mt-1"
>
<div className="h-5 w-5 rounded border border-dashed border-neutral-300 dark:border-neutral-600 flex items-center justify-center">
<div className="h-5 w-5 rounded border border-dashed border-neutral-300 dark:border-neutral-600 flex items-center justify-center" aria-hidden="true">
<PlusIcon className="h-3 w-3" />
</div>
<span>Create Organization</span>

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'
import { formatNumber } from '@ciphera-net/ui'
import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
import { TopPage, getTopPages, getEntryPages, getExitPages } from '@/lib/api/stats'
import { Modal, ArrowUpRightIcon, LayoutDashboardIcon } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons'
@@ -22,6 +23,7 @@ const LIMIT = 7
export default function ContentStats({ topPages, entryPages, exitPages, domain, collectPagePaths = true, siteId, dateRange }: ContentStatsProps) {
const [activeTab, setActiveTab] = useState<Tab>('top_pages')
const handleTabKeyDown = useTabListKeyboard()
const [isModalOpen, setIsModalOpen] = useState(false)
const [fullData, setFullData] = useState<TopPage[]>([])
const [isLoadingFull, setIsLoadingFull] = useState(false)
@@ -103,7 +105,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
</button>
)}
</div>
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Content view tabs">
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Content view tabs" onKeyDown={handleTabKeyDown}>
{(['top_pages', 'entry_pages', 'exit_pages'] as Tab[]).map((tab) => (
<button
key={tab}

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'
import { formatNumber } from '@ciphera-net/ui'
import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
import * as Flags from 'country-flag-icons/react/3x2'
import iso3166 from 'iso-3166-2'
import WorldMap from './WorldMap'
@@ -26,6 +27,7 @@ const LIMIT = 7
export default function Locations({ countries, cities, regions, geoDataLevel = 'full', siteId, dateRange }: LocationProps) {
const [activeTab, setActiveTab] = useState<Tab>('map')
const handleTabKeyDown = useTabListKeyboard()
const [isModalOpen, setIsModalOpen] = useState(false)
type LocationItem = { country?: string; city?: string; region?: string; pageviews: number }
const [fullData, setFullData] = useState<LocationItem[]>([])
@@ -203,7 +205,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
</button>
)}
</div>
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Location view tabs">
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Location view tabs" onKeyDown={handleTabKeyDown}>
{(['map', 'countries', 'regions', 'cities'] as Tab[]).map((tab) => (
<button
key={tab}

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'
import { formatNumber } from '@ciphera-net/ui'
import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
import { getBrowserIcon, getOSIcon, getDeviceIcon } from '@/lib/utils/icons'
import { MdMonitor } from 'react-icons/md'
import { Modal, GridIcon } from '@ciphera-net/ui'
@@ -25,6 +26,7 @@ const LIMIT = 7
export default function TechSpecs({ browsers, os, devices, screenResolutions, collectDeviceInfo = true, collectScreenResolution = true, siteId, dateRange }: TechSpecsProps) {
const [activeTab, setActiveTab] = useState<Tab>('browsers')
const handleTabKeyDown = useTabListKeyboard()
const [isModalOpen, setIsModalOpen] = useState(false)
type TechItem = { name: string; pageviews: number; icon: React.ReactNode }
const [fullData, setFullData] = useState<TechItem[]>([])
@@ -127,7 +129,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
</button>
)}
</div>
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Technology view tabs">
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Technology view tabs" onKeyDown={handleTabKeyDown}>
{(['browsers', 'os', 'devices', 'screens'] as Tab[]).map((tab) => (
<button
key={tab}

View File

@@ -26,6 +26,7 @@ function BellIcon({ className }: { className?: string }) {
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
@@ -83,16 +84,22 @@ export default function NotificationCenter() {
return () => clearInterval(id)
}, [])
// * Close dropdown when clicking outside
// * Close dropdown when clicking outside or pressing Escape
useEffect(() => {
if (!open) return
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
if (open) {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleKeyDown)
}
}, [open])
@@ -128,23 +135,32 @@ export default function NotificationCenter() {
<button
type="button"
onClick={() => setOpen(!open)}
aria-expanded={open}
aria-haspopup="true"
aria-controls={open ? 'notification-dropdown' : undefined}
className="relative p-2 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg hover:bg-neutral-100/50 dark:hover:bg-neutral-800/50 transition-colors"
aria-label={unreadCount > 0 ? `${unreadCount} unread notifications` : 'Notifications'}
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : 'Notifications'}
>
<BellIcon />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 w-2 h-2 bg-brand-orange rounded-full" />
<span className="absolute top-1 right-1 w-2 h-2 bg-brand-orange rounded-full" aria-hidden="true" />
)}
</button>
{open && (
<div className="absolute right-0 top-full mt-2 w-80 sm:w-96 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden z-[100]">
<div
id="notification-dropdown"
role="dialog"
aria-label="Notifications"
className="absolute right-0 top-full mt-2 w-80 sm:w-96 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden z-[100]"
>
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-200 dark:border-neutral-700">
<h3 className="font-semibold text-neutral-900 dark:text-white">Notifications</h3>
{unreadCount > 0 && (
<button
type="button"
onClick={handleMarkAllRead}
aria-label="Mark all notifications as read"
className="text-sm text-brand-orange hover:underline"
>
Mark all read
@@ -202,12 +218,10 @@ export default function NotificationCenter() {
</div>
</Link>
) : (
<div
role="button"
tabIndex={0}
<button
type="button"
onClick={() => handleNotificationClick(n)}
onKeyDown={(e) => e.key === 'Enter' && handleNotificationClick(n)}
className={`block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 cursor-pointer ${!n.read ? 'bg-brand-orange/5 dark:bg-brand-orange/10' : ''}`}
className={`w-full text-left block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 cursor-pointer ${!n.read ? 'bg-brand-orange/5 dark:bg-brand-orange/10' : ''}`}
>
<div className="flex gap-3">
{getTypeIcon(n.type)}
@@ -225,7 +239,7 @@ export default function NotificationCenter() {
</p>
</div>
</div>
</div>
</button>
)}
</li>
))}
@@ -246,7 +260,7 @@ export default function NotificationCenter() {
onClick={() => setOpen(false)}
className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors"
>
<SettingsIcon className="w-4 h-4" />
<SettingsIcon className="w-4 h-4" aria-hidden="true" />
Manage settings
</Link>
</div>

View File

@@ -0,0 +1,28 @@
'use client'
import { useCallback } from 'react'
/**
* Provides an onKeyDown handler for WAI-ARIA tab lists.
* Moves focus between sibling `[role="tab"]` buttons with Left/Right arrow keys.
*/
export function useTabListKeyboard() {
return useCallback((e: React.KeyboardEvent<HTMLElement>) => {
const target = e.currentTarget
const tabs = Array.from(target.querySelectorAll<HTMLElement>('[role="tab"]'))
const index = tabs.indexOf(e.target as HTMLElement)
if (index < 0) return
let next: number | null = null
if (e.key === 'ArrowRight') next = (index + 1) % tabs.length
else if (e.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length
else if (e.key === 'Home') next = 0
else if (e.key === 'End') next = tabs.length - 1
if (next !== null) {
e.preventDefault()
tabs[next].focus()
tabs[next].click()
}
}, [])
}

View File

@@ -22,7 +22,7 @@ export function formatTimeAgo(dateStr: string): string {
*/
export function getTypeIcon(type: string) {
if (type.includes('down') || type.includes('degraded') || type.startsWith('billing_')) {
return <AlertTriangleIcon className="w-4 h-4 shrink-0 text-amber-500" />
return <AlertTriangleIcon className="w-4 h-4 shrink-0 text-amber-500" aria-hidden="true" />
}
return <CheckCircleIcon className="w-4 h-4 shrink-0 text-emerald-500" />
return <CheckCircleIcon className="w-4 h-4 shrink-0 text-emerald-500" aria-hidden="true" />
}