Skip to content
2 changes: 1 addition & 1 deletion apps/web/src/components/AppFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ export function AppFooter() {
Copyright © Code for Philly {FOUNDED_YEAR}–{CURRENT_YEAR}
</p>
<a
href="https://github.com/CodeForPhilly/codeforphilly-rewrite"
href="https://github.com/CodeForPhilly/codeforphilly-ng"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
Expand Down
125 changes: 89 additions & 36 deletions apps/web/src/components/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,19 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
import { Separator } from '@/components/ui/separator';
import { SearchBox } from '@/components/SearchBox';
import { useAuth } from '@/hooks/useAuth';

const GITHUB_URL = 'https://github.com/CodeForPhilly';

function ChevronDownIcon() {
return (
<svg
Expand Down Expand Up @@ -53,14 +61,29 @@ function MenuIcon() {
);
}

function GitHubIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
);
}

function AuthControls({ mobile = false }: { mobile?: boolean }) {
const { person, loading, signOut } = useAuth();

if (loading) {
return (
<div
className={`h-8 ${mobile ? 'w-full' : 'w-20'} bg-muted animate-pulse rounded`}
aria-label="Loading auth state"
aria-hidden="true"
/>
);
}
Expand Down Expand Up @@ -143,12 +166,8 @@ function AboutDropdown() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="flex items-center gap-1"
aria-label="About menu"
>
{/* No aria-label: the visible "About" text is the accessible name. */}
<Button variant="ghost" size="sm" className="flex items-center gap-1">
About <ChevronDownIcon />
</Button>
</DropdownMenuTrigger>
Expand Down Expand Up @@ -181,6 +200,21 @@ const navLinkClass = ({ isActive }: { isActive: boolean }) =>
isActive ? 'text-primary' : 'text-muted-foreground'
}`;

function GitHubLink() {
return (
<Button asChild variant="ghost" size="icon-sm">
<a
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
aria-label="Code for Philly on GitHub"
>
<GitHubIcon />
</a>
</Button>
);
}

export function AppHeader() {
const [mobileOpen, setMobileOpen] = useState(false);

Expand All @@ -201,58 +235,64 @@ export function AppHeader() {
/>
</Link>

{/* Desktop nav */}
{/* Desktop content cluster. The parent gap is the only source of
spacing between children — no per-child margins. */}
<nav
aria-label="Primary navigation"
className="hidden md:flex items-center gap-1 ml-4 flex-1"
className="hidden md:flex items-center gap-2 ml-4 flex-1"
>
<NavLink to="/projects" className={navLinkClass}>
Projects
</NavLink>
<NavLink
to="/help-wanted"
className={({ isActive }) => navLinkClass({ isActive }) + ' ml-1'}
>
<NavLink to="/help-wanted" className={navLinkClass}>
Help Wanted
</NavLink>
<NavLink
to="/members"
className={({ isActive }) => navLinkClass({ isActive }) + ' ml-1'}
>
<NavLink to="/members" className={navLinkClass}>
Members
</NavLink>
<Button asChild size="sm" className="ml-1 bg-green-600 hover:bg-green-700 text-white">
<NavLink to="/volunteer">Volunteer</NavLink>
</Button>
<div className="ml-1">
<AboutDropdown />
</div>
<AboutDropdown />
</nav>

{/* Desktop: search + auth */}
{/* Desktop utility cluster: GitHub, search, auth, then the Volunteer
CTA pinned rightmost (specs/behaviors/app-shell.md). */}
<div className="hidden md:flex items-center gap-2 ml-auto">
<GitHubLink />
<SearchBox />
<AuthControls />
<Button
asChild
size="sm"
className="bg-green-600 hover:bg-green-700 text-white"
>
<NavLink to="/volunteer">Volunteer</NavLink>
</Button>
</div>

{/* Mobile: auth + hamburger */}
<div className="flex md:hidden items-center gap-2 ml-auto">
<AuthControls />
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetTrigger asChild>
{/* No aria-expanded here — Radix's Dialog.Trigger supplies it. */}
<Button
variant="ghost"
size="sm"
aria-label="Open navigation menu"
aria-expanded={mobileOpen}
>
<MenuIcon />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-72 flex flex-col gap-4 pt-8">
<SheetContent side="right" className="w-72">
{/* SheetHeader/SheetTitle carry the panel's own padding and give
the underlying Radix dialog its accessible name. */}
<SheetHeader className="pb-0">
<SheetTitle>Menu</SheetTitle>
</SheetHeader>
{/* min-h-0 + overflow-y-auto so the list stays reachable on
short viewports instead of overflowing the panel. */}
<nav
aria-label="Mobile navigation"
className="flex flex-col gap-2"
className="flex flex-col gap-2 px-4 min-h-0 overflow-y-auto"
>
<NavLink
to="/projects"
Expand All @@ -275,13 +315,6 @@ export function AppHeader() {
>
Members
</NavLink>
<NavLink
to="/volunteer"
className={navLinkClass}
onClick={() => setMobileOpen(false)}
>
Volunteer
</NavLink>
<Separator />
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wide">
About
Expand Down Expand Up @@ -324,12 +357,32 @@ export function AppHeader() {
<a
href="mailto:hello@codeforphilly.org"
className="text-sm font-medium text-muted-foreground hover:text-primary"
onClick={() => setMobileOpen(false)}
>
Contact
</a>
<Separator />
<a
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-muted-foreground hover:text-primary"
onClick={() => setMobileOpen(false)}
>
GitHub
</a>
<NavLink
to="/volunteer"
className={navLinkClass}
onClick={() => setMobileOpen(false)}
>
Volunteer
</NavLink>
</nav>
<Separator />
<SearchBox inline />
<div className="px-4 pb-4">
<SearchBox inline />
</div>
</SheetContent>
</Sheet>
</div>
Expand Down
9 changes: 5 additions & 4 deletions apps/web/src/screens/Volunteer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import { HelpWantedCard } from '@/components/HelpWantedCard';
import { useAuth } from '@/hooks/useAuth';
import { api } from '@/lib/api';

const HACK_NIGHT_URL =
'https://codeforphilly.gitbook.io/projects/contributing-to-projects/hack-night-program-details';
// The codeforphilly.gitbook.io space is gone (404 "Content owner not found"),
// so both of these point at the surviving live equivalents.
const MEETUP_URL = 'https://www.meetup.com/Code-for-Philly/';
const START_PROJECT_URL =
'https://codeforphilly.gitbook.io/projects/creating-new-partnerships/first-steps';
'https://github.com/CodeForPhilly/partnerships/blob/master/creating-new-partnerships/first-steps.md';

export function Volunteer() {
const { person } = useAuth();
Expand Down Expand Up @@ -69,7 +70,7 @@ export function Volunteer() {
We meet weekly. Bring your laptop, or just yourself.
</p>
<Button asChild variant="outline" size="sm">
<a href={HACK_NIGHT_URL} target="_blank" rel="noopener noreferrer">
<a href={MEETUP_URL} target="_blank" rel="noopener noreferrer">
When we meet →
</a>
</Button>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/tests/AppFooter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe('AppFooter', () => {
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute(
'href',
'https://github.com/CodeForPhilly/codeforphilly-rewrite',
'https://github.com/CodeForPhilly/codeforphilly-ng',
);
});

Expand Down
52 changes: 49 additions & 3 deletions apps/web/tests/AppHeader.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithRouter } from './test-utils.js';
import { AppHeader } from '../src/components/AppHeader.js';
Expand Down Expand Up @@ -38,10 +38,25 @@ describe('AppHeader', () => {

it('renders primary nav links', async () => {
renderWithRouter(<Wrapped />);
const nav = screen.getByRole('navigation', { name: /primary navigation/i });
expect(screen.getByRole('link', { name: 'Projects' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Help Wanted' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Members' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Volunteer' })).toBeInTheDocument();
expect(within(nav).getByRole('button', { name: 'About' })).toBeInTheDocument();

// The Volunteer CTA lives in the utility cluster, not the content nav —
// it is the rightmost header element (specs/behaviors/app-shell.md).
const volunteer = screen.getByRole('link', { name: 'Volunteer' });
expect(volunteer).toHaveAttribute('href', '/volunteer');
expect(nav).not.toContainElement(volunteer);
});

it('renders the GitHub link in the utility cluster', async () => {
renderWithRouter(<Wrapped />);
const gh = screen.getByRole('link', { name: 'Code for Philly on GitHub' });
expect(gh).toHaveAttribute('href', 'https://github.com/CodeForPhilly');
expect(gh).toHaveAttribute('target', '_blank');
expect(gh).toHaveAttribute('rel', 'noopener noreferrer');
});

it('shows Sign in button(s) when anonymous', async () => {
Expand All @@ -59,7 +74,8 @@ describe('AppHeader', () => {
const user = userEvent.setup();
renderWithRouter(<Wrapped />);

const aboutBtn = screen.getByRole('button', { name: /about menu/i });
// The trigger's visible text is its accessible name — no aria-label.
const aboutBtn = screen.getByRole('button', { name: 'About' });
await user.click(aboutBtn);

await waitFor(() => {
Expand All @@ -75,6 +91,8 @@ describe('AppHeader', () => {

const hamburger = screen.getByRole('button', { name: /open navigation menu/i });
expect(hamburger).toBeInTheDocument();
// aria-expanded is supplied by Radix's Dialog.Trigger, not hand-written.
expect(hamburger).toHaveAttribute('aria-expanded', 'false');

// Open
await user.click(hamburger);
Expand All @@ -83,6 +101,7 @@ describe('AppHeader', () => {
// Sheet content includes "Mobile navigation" aria-label
expect(screen.getByRole('navigation', { name: /mobile navigation/i })).toBeInTheDocument();
});
expect(hamburger).toHaveAttribute('aria-expanded', 'true');

// Close via Escape key
await user.keyboard('{Escape}');
Expand All @@ -91,4 +110,31 @@ describe('AppHeader', () => {
expect(screen.queryByRole('navigation', { name: /mobile navigation/i })).not.toBeInTheDocument();
});
});

it('gives the mobile sheet dialog an accessible name', async () => {
const user = userEvent.setup();
renderWithRouter(<Wrapped />);

await user.click(screen.getByRole('button', { name: /open navigation menu/i }));

const dialog = await screen.findByRole('dialog', { name: 'Menu' });
expect(dialog).toBeInTheDocument();
});

it('lists GitHub and Volunteer in the mobile sheet', async () => {
const user = userEvent.setup();
renderWithRouter(<Wrapped />);

await user.click(screen.getByRole('button', { name: /open navigation menu/i }));

const nav = await screen.findByRole('navigation', { name: /mobile navigation/i });
expect(within(nav).getByRole('link', { name: 'GitHub' })).toHaveAttribute(
'href',
'https://github.com/CodeForPhilly',
);
expect(within(nav).getByRole('link', { name: 'Volunteer' })).toHaveAttribute(
'href',
'/volunteer',
);
});
});
Loading