fix(ux): accessible sidebar/drawer, distinct Ops icon, skip link

- Ops nav now uses Wrench (was Settings2, collided with Settings)
- aria-current on active nav link, aria-label on primary nav
- mobile drawer: role=dialog/aria-modal, Escape to close, body
  scroll lock, focus moves into drawer and back to the menu button
- larger touch targets on the menu open/close buttons
- sidebar logo uses the shared BrandMark instead of an ad-hoc {hs}
- skip-to-content link added to the app layout
This commit is contained in:
Moheeb Zara
2026-05-15 22:12:52 -07:00
parent fa71d9a63e
commit 3360f54232
2 changed files with 50 additions and 10 deletions
+7 -1
View File
@@ -57,13 +57,19 @@ export default async function AppLayout({ children }: { children: React.ReactNod
return ( return (
<div className="flex h-screen bg-background overflow-hidden"> <div className="flex h-screen bg-background overflow-hidden">
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:z-[100] focus:top-2 focus:left-2 focus:rounded-md focus:bg-primary focus:px-3 focus:py-2 focus:text-sm focus:text-primary-foreground"
>
Skip to content
</a>
<AppSidebar <AppSidebar
member={member} member={member}
roleName={roleName} roleName={roleName}
taskBadge={taskCount ?? 0} taskBadge={taskCount ?? 0}
paymentBadge={paymentCount ?? 0} paymentBadge={paymentCount ?? 0}
/> />
<main className="flex-1 overflow-y-auto pt-[52px] md:pt-0 flex flex-col"> <main id="main-content" tabIndex={-1} className="flex-1 overflow-y-auto pt-[52px] md:pt-0 flex flex-col">
<div className="flex-1">{children}</div> <div className="flex-1">{children}</div>
{member.spaces && (member.spaces as { mission_statement?: string | null }).mission_statement && ( {member.spaces && (member.spaces as { mission_statement?: string | null }).mission_statement && (
<footer className="border-t border-border bg-card px-4 md:px-6 py-3"> <footer className="border-t border-border bg-card px-4 md:px-6 py-3">
+43 -9
View File
@@ -2,15 +2,16 @@
import Link from 'next/link' import Link from 'next/link'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { signOut } from '@/lib/auth-actions' import { signOut } from '@/lib/auth-actions'
import { BrandMark } from '@/components/brand-mark'
import type { Tables } from '@/types/database' import type { Tables } from '@/types/database'
type SpaceMember = Tables<'space_members'> type SpaceMember = Tables<'space_members'>
type Space = Tables<'spaces'> type Space = Tables<'spaces'>
import { import {
Menu, X, LayoutDashboard, ListChecks, FolderKanban, Settings2, Menu, X, LayoutDashboard, ListChecks, FolderKanban, Settings2, Wrench,
MessageSquare, Users, CreditCard, BookUser, Download, LogOut, MessageSquare, Users, CreditCard, BookUser, Download, LogOut,
Vote, ShieldAlert, ScrollText, LineChart, UserCircle, UserSearch, Vote, ShieldAlert, ScrollText, LineChart, UserCircle, UserSearch,
MessagesSquare, SlidersHorizontal, MessagesSquare, SlidersHorizontal,
@@ -30,6 +31,7 @@ function NavLink({ href, label, icon: Icon, badge, active, onClick }: NavLinkPro
<Link <Link
href={href} href={href}
onClick={onClick} onClick={onClick}
aria-current={active ? 'page' : undefined}
className={cn( className={cn(
'flex items-center justify-between mx-2 px-3 py-2 rounded-md text-sm transition-colors', 'flex items-center justify-between mx-2 px-3 py-2 rounded-md text-sm transition-colors',
active active
@@ -64,6 +66,26 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa
const initials = (member.display_name || 'U').split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) const initials = (member.display_name || 'U').split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
const isAdmin = member.role === 'admin' || member.role === 'board' const isAdmin = member.role === 'admin' || member.role === 'board'
const [drawerOpen, setDrawerOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false)
const menuButtonRef = useRef<HTMLButtonElement>(null)
const drawerCloseRef = useRef<HTMLButtonElement>(null)
// The mobile drawer is a modal surface: lock background scroll, close on
// Escape, and move focus into and back out of it for keyboard/AT users.
useEffect(() => {
if (!drawerOpen) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setDrawerOpen(false)
}
document.addEventListener('keydown', onKeyDown)
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
drawerCloseRef.current?.focus()
return () => {
document.removeEventListener('keydown', onKeyDown)
document.body.style.overflow = prevOverflow
menuButtonRef.current?.focus()
}
}, [drawerOpen])
const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/') const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/')
@@ -79,21 +101,21 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa
<div className="px-5 py-5 border-b border-[var(--sidebar-border)]"> <div className="px-5 py-5 border-b border-[var(--sidebar-border)]">
<Link href="/dashboard" onClick={onNav} className="flex items-center gap-2.5"> <Link href="/dashboard" onClick={onNav} className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded bg-[var(--sidebar-primary)] flex items-center justify-center"> <div className="w-8 h-8 rounded bg-[var(--sidebar-primary)] flex items-center justify-center">
<span className="text-white font-mono text-xs font-bold">{'{'}<span>hs</span>{'}'}</span> <BrandMark className="w-4 h-4 text-white" />
</div> </div>
</Link> </Link>
<p className="text-[var(--sidebar-foreground)]/60 text-xs mt-1.5 font-mono truncate">{space?.name || 'My Space'}</p> <p className="text-[var(--sidebar-foreground)]/60 text-xs mt-1.5 font-mono truncate">{space?.name || 'My Space'}</p>
</div> </div>
{/* Nav */} {/* Nav */}
<nav className="flex-1 py-4 overflow-y-auto"> <nav aria-label="Primary" className="flex-1 py-4 overflow-y-auto">
<div className="px-3 mb-1"> <div className="px-3 mb-1">
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">Workspace</p> <p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">Workspace</p>
</div> </div>
<NavLink href="/dashboard" label="Dashboard" icon={LayoutDashboard} active={isActive('/dashboard')} onClick={onNav} /> <NavLink href="/dashboard" label="Dashboard" icon={LayoutDashboard} active={isActive('/dashboard')} onClick={onNav} />
<NavLink href="/tasks" label="Tasks & Chores" icon={ListChecks} active={isActive('/tasks')} badge={taskBadge} onClick={onNav} /> <NavLink href="/tasks" label="Tasks & Chores" icon={ListChecks} active={isActive('/tasks')} badge={taskBadge} onClick={onNav} />
<NavLink href="/projects" label="Projects" icon={FolderKanban} active={isActive('/projects')} onClick={onNav} /> <NavLink href="/projects" label="Projects" icon={FolderKanban} active={isActive('/projects')} onClick={onNav} />
<NavLink href="/ops" label="Ops & Facilities" icon={Settings2} active={isActive('/ops')} onClick={onNav} /> <NavLink href="/ops" label="Ops & Facilities" icon={Wrench} active={isActive('/ops')} onClick={onNav} />
<NavLink href="/comms" label="Comms" icon={MessageSquare} active={isActive('/comms')} badge={commsBadge} onClick={onNav} /> <NavLink href="/comms" label="Comms" icon={MessageSquare} active={isActive('/comms')} badge={commsBadge} onClick={onNav} />
<NavLink href="/forum" label="Forum" icon={MessagesSquare} active={isActive('/forum')} onClick={onNav} /> <NavLink href="/forum" label="Forum" icon={MessagesSquare} active={isActive('/forum')} onClick={onNav} />
@@ -158,14 +180,16 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa
<div className="md:hidden fixed top-0 left-0 right-0 z-40 bg-[var(--sidebar)] border-b border-[var(--sidebar-border)] flex items-center justify-between px-4 h-[52px]"> <div className="md:hidden fixed top-0 left-0 right-0 z-40 bg-[var(--sidebar)] border-b border-[var(--sidebar-border)] flex items-center justify-between px-4 h-[52px]">
<Link href="/dashboard" className="flex items-center gap-2"> <Link href="/dashboard" className="flex items-center gap-2">
<div className="w-7 h-7 rounded bg-[var(--sidebar-primary)] flex items-center justify-center"> <div className="w-7 h-7 rounded bg-[var(--sidebar-primary)] flex items-center justify-center">
<span className="text-white font-mono text-[10px] font-bold">hs</span> <BrandMark className="w-3.5 h-3.5 text-white" />
</div> </div>
<span className="text-[var(--sidebar-foreground)]/60 text-xs font-mono truncate max-w-[150px]">{space?.name}</span> <span className="text-[var(--sidebar-foreground)]/60 text-xs font-mono truncate max-w-[150px]">{space?.name}</span>
</Link> </Link>
<button <button
ref={menuButtonRef}
onClick={() => setDrawerOpen(true)} onClick={() => setDrawerOpen(true)}
className="text-[var(--sidebar-foreground)]/70 hover:text-[var(--sidebar-foreground)] p-1" className="text-[var(--sidebar-foreground)]/70 hover:text-[var(--sidebar-foreground)] p-2.5 -mr-2"
aria-label="Open menu" aria-label="Open menu"
aria-expanded={drawerOpen}
> >
<Menu className="w-5 h-5" /> <Menu className="w-5 h-5" />
</button> </button>
@@ -175,10 +199,20 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa
{drawerOpen && ( {drawerOpen && (
<div className="md:hidden fixed inset-0 z-50 flex"> <div className="md:hidden fixed inset-0 z-50 flex">
<div className="absolute inset-0 bg-black/50" onClick={closeDrawer} /> <div className="absolute inset-0 bg-black/50" onClick={closeDrawer} />
<div className="relative w-[280px] bg-[var(--sidebar)] flex flex-col h-full shadow-xl"> <div
role="dialog"
aria-modal="true"
aria-label="Menu"
className="relative w-[280px] bg-[var(--sidebar)] flex flex-col h-full shadow-xl"
>
<div className="flex items-center justify-between px-4 h-[52px] border-b border-[var(--sidebar-border)]"> <div className="flex items-center justify-between px-4 h-[52px] border-b border-[var(--sidebar-border)]">
<span className="text-[var(--sidebar-foreground)] text-sm font-mono font-bold">Menu</span> <span className="text-[var(--sidebar-foreground)] text-sm font-mono font-bold">Menu</span>
<button onClick={closeDrawer} className="text-[var(--sidebar-foreground)]/50 hover:text-[var(--sidebar-foreground)]"> <button
ref={drawerCloseRef}
onClick={closeDrawer}
className="text-[var(--sidebar-foreground)]/50 hover:text-[var(--sidebar-foreground)] p-2.5 -mr-2"
aria-label="Close menu"
>
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
</div> </div>