+
Organizations
@@ -74,21 +74,28 @@ export default function OrganizationSwitcher({ orgs, activeOrgId }: { orgs: Orga
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'
}`}
>
-
+
{org.organization_name}
- {switching === org.organization_id && Loading... }
- {activeOrgId === org.organization_id && !switching && }
+ {switching === org.organization_id && Switching… }
+ {activeOrgId === org.organization_id && !switching && (
+ <>
+
+ (current)
+ >
+ )}
))}
@@ -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"
>
-
+
Create Organization
diff --git a/components/dashboard/ContentStats.tsx b/components/dashboard/ContentStats.tsx
index 49f3363..42c8169 100644
--- a/components/dashboard/ContentStats.tsx
+++ b/components/dashboard/ContentStats.tsx
@@ -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
('top_pages')
+ const handleTabKeyDown = useTabListKeyboard()
const [isModalOpen, setIsModalOpen] = useState(false)
const [fullData, setFullData] = useState([])
const [isLoadingFull, setIsLoadingFull] = useState(false)
@@ -103,7 +105,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
)}
-
+
{(['top_pages', 'entry_pages', 'exit_pages'] as Tab[]).map((tab) => (
('map')
+ const handleTabKeyDown = useTabListKeyboard()
const [isModalOpen, setIsModalOpen] = useState(false)
type LocationItem = { country?: string; city?: string; region?: string; pageviews: number }
const [fullData, setFullData] = useState([])
@@ -203,7 +205,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
)}
-
+
{(['map', 'countries', 'regions', 'cities'] as Tab[]).map((tab) => (
('browsers')
+ const handleTabKeyDown = useTabListKeyboard()
const [isModalOpen, setIsModalOpen] = useState(false)
type TechItem = { name: string; pageviews: number; icon: React.ReactNode }
const [fullData, setFullData] = useState([])
@@ -127,7 +129,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
)}
-
+
{(['browsers', 'os', 'devices', 'screens'] as Tab[]).map((tab) => (
@@ -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() {
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'}
>
{unreadCount > 0 && (
-
+
)}
{open && (
-
+
Notifications
{unreadCount > 0 && (
Mark all read
@@ -202,12 +218,10 @@ export default function NotificationCenter() {
) : (
-
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' : ''}`}
>
{getTypeIcon(n.type)}
@@ -225,7 +239,7 @@ export default function NotificationCenter() {
-
+
)}
))}
@@ -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"
>
-
+
Manage settings
diff --git a/lib/hooks/useTabListKeyboard.ts b/lib/hooks/useTabListKeyboard.ts
new file mode 100644
index 0000000..269407b
--- /dev/null
+++ b/lib/hooks/useTabListKeyboard.ts
@@ -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) => {
+ const target = e.currentTarget
+ const tabs = Array.from(target.querySelectorAll('[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()
+ }
+ }, [])
+}
diff --git a/lib/utils/notifications.tsx b/lib/utils/notifications.tsx
index 9ed6c7c..b4441f1 100644
--- a/lib/utils/notifications.tsx
+++ b/lib/utils/notifications.tsx
@@ -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
+ return
}
- return
+ return
}