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 (
<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
member={member}
roleName={roleName}
taskBadge={taskCount ?? 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>
{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">
+43 -9
View File
@@ -2,15 +2,16 @@
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
import { signOut } from '@/lib/auth-actions'
import { BrandMark } from '@/components/brand-mark'
import type { Tables } from '@/types/database'
type SpaceMember = Tables<'space_members'>
type Space = Tables<'spaces'>
import {
Menu, X, LayoutDashboard, ListChecks, FolderKanban, Settings2,
Menu, X, LayoutDashboard, ListChecks, FolderKanban, Settings2, Wrench,
MessageSquare, Users, CreditCard, BookUser, Download, LogOut,
Vote, ShieldAlert, ScrollText, LineChart, UserCircle, UserSearch,
MessagesSquare, SlidersHorizontal,
@@ -30,6 +31,7 @@ function NavLink({ href, label, icon: Icon, badge, active, onClick }: NavLinkPro
<Link
href={href}
onClick={onClick}
aria-current={active ? 'page' : undefined}
className={cn(
'flex items-center justify-between mx-2 px-3 py-2 rounded-md text-sm transition-colors',
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 isAdmin = member.role === 'admin' || member.role === 'board'
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 + '/')
@@ -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)]">
<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">
<span className="text-white font-mono text-xs font-bold">{'{'}<span>hs</span>{'}'}</span>
<BrandMark className="w-4 h-4 text-white" />
</div>
</Link>
<p className="text-[var(--sidebar-foreground)]/60 text-xs mt-1.5 font-mono truncate">{space?.name || 'My Space'}</p>
</div>
{/* 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">
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">Workspace</p>
</div>
<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="/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="/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]">
<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">
<span className="text-white font-mono text-[10px] font-bold">hs</span>
<BrandMark className="w-3.5 h-3.5 text-white" />
</div>
<span className="text-[var(--sidebar-foreground)]/60 text-xs font-mono truncate max-w-[150px]">{space?.name}</span>
</Link>
<button
ref={menuButtonRef}
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-expanded={drawerOpen}
>
<Menu className="w-5 h-5" />
</button>
@@ -175,10 +199,20 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa
{drawerOpen && (
<div className="md:hidden fixed inset-0 z-50 flex">
<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)]">
<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" />
</button>
</div>