feat: enhance accessibility across the application by improving keyboard navigation and screen reader support for various components
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
28
lib/hooks/useTabListKeyboard.ts
Normal file
28
lib/hooks/useTabListKeyboard.ts
Normal 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()
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -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" />
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user