diff --git a/src/App.jsx b/src/App.jsx index 56d854d2..af1309a0 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -19,10 +19,18 @@ import RequireAnalysis from './components/RequireAnalysis' function Layout({ children }) { return ( -
+
-
{children}
+
+ {children} +
) diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx index bdafb79e..54b8554f 100644 --- a/src/components/Navbar.jsx +++ b/src/components/Navbar.jsx @@ -1,27 +1,55 @@ -import React from 'react' -import { NavLink, useNavigate } from 'react-router-dom' -import { FiHeart, FiSettings, FiZap } from 'react-icons/fi' +import React, { useState, useEffect, useRef } from 'react' +import { NavLink, useNavigate, useLocation } from 'react-router-dom' +import { FiHeart, FiSettings, FiZap, FiMenu, FiX } from 'react-icons/fi' import { useApp } from '../context/AppContext' import ThemeToggle from './ThemeToggle' import Logo from "../assets/og-logo.svg?react"; import { useTheme } from '../context/ThemeContext' - -const LINKS = [ - { to: '/overview', label: 'Overview' }, - { to: '/repositories', label: 'Repositories' }, - { to: '/contributors', label: 'Contributors' }, - { to: '/network', label: 'Network' }, - { to: '/analytics', label: 'Analytics' }, - { to: '/governance', label: 'Governance' }, -] +import { + THEME_LABEL, + API_CALLS_REMAINING_LABEL, + SETTINGS_LABEL, + SUPPORT_US_LABEL, + HAMBURGER_LABEL, + NAV_LINKS as LINKS, +} from '../constants/navbar' export default function Navbar() { const { orgs, rateLimit } = useApp() const { theme } = useTheme(); const navigate = useNavigate() + const location = useLocation() + const [menuOpen, setMenuOpen] = useState(false) + const hamburgerRef = useRef(null) + const wasOpenRef = useRef(false) const hasData = orgs.length > 0 const lowLimit = rateLimit && rateLimit.remaining < 15 + const closeMenu = () => setMenuOpen(false) + + // Restore focus to hamburger button when mobile menu closes + useEffect(() => { + if (wasOpenRef.current && !menuOpen) { + hamburgerRef.current?.focus() + } + wasOpenRef.current = menuOpen + }, [menuOpen]) + + // Close mobile menu on route change + useEffect(() => { + setMenuOpen(false) + }, [location.pathname]) + + // Close mobile menu on Escape key press + useEffect(() => { + if (!menuOpen) return + const handleKeyDown = e => { + if (e.key === 'Escape') setMenuOpen(false) + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [menuOpen]) + return ( ) } + + diff --git a/src/components/Navbar.test.jsx b/src/components/Navbar.test.jsx new file mode 100644 index 00000000..b159f72b --- /dev/null +++ b/src/components/Navbar.test.jsx @@ -0,0 +1,192 @@ +import React from 'react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen, fireEvent, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' +import Navbar from './Navbar' + +const mockAppState = { + orgs: [{ login: 'AOSSIE-Org' }], + rateLimit: { remaining: 50, limit: 60 }, +} + +const mockThemeState = { + theme: 'dark', + toggleTheme: vi.fn(), +} + +vi.mock('../context/AppContext', () => ({ + useApp: () => mockAppState, +})) + +vi.mock('../context/ThemeContext', () => ({ + useTheme: () => mockThemeState, +})) + +function renderNavbar(initialRoute = '/') { + return render( + + + + ) +} + +describe('Navbar Mobile Navigation', () => { + beforeEach(() => { + mockAppState.orgs = [{ login: 'AOSSIE-Org' }] + mockAppState.rateLimit = { remaining: 50, limit: 60 } + mockThemeState.toggleTheme.mockClear() + }) + + it('renders hamburger button with initial aria-expanded="false"', () => { + renderNavbar() + const hamburger = screen.getByRole('button', { name: /toggle navigation menu/i }) + expect(hamburger).toBeInTheDocument() + expect(hamburger).toHaveAttribute('aria-expanded', 'false') + expect(hamburger).toHaveAttribute('aria-controls', 'mobile-nav-menu') + expect(screen.queryByTestId('navbar-backdrop')).not.toBeInTheDocument() + expect(document.getElementById('mobile-nav-menu')).not.toBeInTheDocument() + }) + + it('opens mobile menu and sets aria-expanded="true" when hamburger is clicked', async () => { + renderNavbar() + const hamburger = screen.getByRole('button', { name: /toggle navigation menu/i }) + + await userEvent.click(hamburger) + + expect(hamburger).toHaveAttribute('aria-expanded', 'true') + expect(document.getElementById('mobile-nav-menu')).toBeInTheDocument() + expect(screen.getByTestId('navbar-backdrop')).toBeInTheDocument() + }) + + it('renders navigation links and action controls in the mobile menu', async () => { + renderNavbar() + const hamburger = screen.getByRole('button', { name: /toggle navigation menu/i }) + + await userEvent.click(hamburger) + + const mobileMenu = document.getElementById('mobile-nav-menu') + expect(mobileMenu).toBeInTheDocument() + + const menu = within(mobileMenu) + + // Check links scoped to mobile menu + expect(menu.getByRole('link', { name: /overview/i })).toBeInTheDocument() + expect(menu.getByRole('link', { name: /repositories/i })).toBeInTheDocument() + expect(menu.getByRole('link', { name: /contributors/i })).toBeInTheDocument() + + // Check action controls scoped to mobile menu + expect(menu.getByText('Theme')).toBeInTheDocument() + expect(menu.getByRole('button', { name: /settings/i })).toBeInTheDocument() + expect(menu.getByRole('button', { name: /support us/i })).toBeInTheDocument() + }) + + it('closes mobile menu when Escape key is pressed', async () => { + renderNavbar() + const hamburger = screen.getByRole('button', { name: /toggle navigation menu/i }) + + await userEvent.click(hamburger) + expect(hamburger).toHaveAttribute('aria-expanded', 'true') + + fireEvent.keyDown(document, { key: 'Escape' }) + + expect(hamburger).toHaveAttribute('aria-expanded', 'false') + expect(document.getElementById('mobile-nav-menu')).not.toBeInTheDocument() + expect(screen.queryByTestId('navbar-backdrop')).not.toBeInTheDocument() + }) + + it('closes mobile menu when clicking the backdrop overlay', async () => { + renderNavbar() + const hamburger = screen.getByRole('button', { name: /toggle navigation menu/i }) + + await userEvent.click(hamburger) + const backdrop = screen.getByTestId('navbar-backdrop') + expect(backdrop).toBeInTheDocument() + + await userEvent.click(backdrop) + + expect(hamburger).toHaveAttribute('aria-expanded', 'false') + expect(document.getElementById('mobile-nav-menu')).not.toBeInTheDocument() + expect(screen.queryByTestId('navbar-backdrop')).not.toBeInTheDocument() + }) + + it('closes mobile menu when clicking a navigation link', async () => { + renderNavbar() + const hamburger = screen.getByRole('button', { name: /toggle navigation menu/i }) + + await userEvent.click(hamburger) + const mobileMenu = document.getElementById('mobile-nav-menu') + expect(mobileMenu).toBeInTheDocument() + + const mobileOverviewLink = within(mobileMenu).getByRole('link', { name: /overview/i }) + await userEvent.click(mobileOverviewLink) + + expect(hamburger).toHaveAttribute('aria-expanded', 'false') + expect(document.getElementById('mobile-nav-menu')).not.toBeInTheDocument() + }) + + it('closes mobile menu when clicking settings button', async () => { + renderNavbar() + const hamburger = screen.getByRole('button', { name: /toggle navigation menu/i }) + + await userEvent.click(hamburger) + const mobileMenu = document.getElementById('mobile-nav-menu') + expect(mobileMenu).toBeInTheDocument() + + const mobileSettingsBtn = within(mobileMenu).getByRole('button', { name: /settings/i }) + await userEvent.click(mobileSettingsBtn) + + expect(hamburger).toHaveAttribute('aria-expanded', 'false') + expect(document.getElementById('mobile-nav-menu')).not.toBeInTheDocument() + }) + + it('closes mobile menu when clicking support us button', async () => { + renderNavbar() + const hamburger = screen.getByRole('button', { name: /toggle navigation menu/i }) + + await userEvent.click(hamburger) + const mobileMenu = document.getElementById('mobile-nav-menu') + expect(mobileMenu).toBeInTheDocument() + + const mobileSupportBtn = within(mobileMenu).getByRole('button', { name: /support us/i }) + await userEvent.click(mobileSupportBtn) + + expect(hamburger).toHaveAttribute('aria-expanded', 'false') + expect(document.getElementById('mobile-nav-menu')).not.toBeInTheDocument() + }) + + it('closes mobile menu when clicking theme toggle button and calls toggleTheme', async () => { + renderNavbar() + const hamburger = screen.getByRole('button', { name: /toggle navigation menu/i }) + + await userEvent.click(hamburger) + const mobileMenu = document.getElementById('mobile-nav-menu') + expect(mobileMenu).toBeInTheDocument() + + const themeToggleBtn = within(mobileMenu).getByRole('button', { name: /switch to/i }) + await userEvent.click(themeToggleBtn) + + expect(mockThemeState.toggleTheme).toHaveBeenCalled() + expect(hamburger).toHaveAttribute('aria-expanded', 'false') + expect(document.getElementById('mobile-nav-menu')).not.toBeInTheDocument() + expect(document.activeElement).toBe(hamburger) + }) + + it('restores focus to hamburger button when closing mobile menu via theme toggle', async () => { + renderNavbar() + const hamburger = screen.getByRole('button', { name: /toggle navigation menu/i }) + + // Open mobile menu + await userEvent.click(hamburger) + const mobileMenu = document.getElementById('mobile-nav-menu') + expect(mobileMenu).toBeInTheDocument() + + // Find ThemeToggle inside #mobile-nav-menu and activate it + const themeToggleBtn = within(mobileMenu).getByRole('button', { name: /switch to/i }) + await userEvent.click(themeToggleBtn) + + // Verify mobile menu is closed and focus returned to hamburger + expect(document.getElementById('mobile-nav-menu')).not.toBeInTheDocument() + expect(document.activeElement).toBe(hamburger) + }) +}) diff --git a/src/components/RateLimitBanner.jsx b/src/components/RateLimitBanner.jsx index b09369da..7dd523fb 100644 --- a/src/components/RateLimitBanner.jsx +++ b/src/components/RateLimitBanner.jsx @@ -1,11 +1,10 @@ import React from 'react' -import { useNavigate } from 'react-router-dom' +import { Link } from 'react-router-dom' import { FiZap, FiAlertTriangle } from 'react-icons/fi' import { useApp } from '../context/AppContext' export default function RateLimitBanner() { const { rateLimit, pat } = useApp() - const navigate = useNavigate() if (!rateLimit) return null const pct = rateLimit.remaining / rateLimit.limit @@ -20,17 +19,21 @@ export default function RateLimitBanner() { background: crit ? 'rgba(239,68,68,.07)' : 'rgba(245,197,24,.06)', padding: '9px 24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', + flexWrap: 'wrap', + gap: '8px 16px', }}> - - - API RATE LIMIT: {rateLimit.remaining} / {rateLimit.limit} REQUESTS REMAINING + + + + API RATE LIMIT: {rateLimit.remaining} / {rateLimit.limit} REQUESTS REMAINING + {!pat && ( - navigate('/settings')} - style={{ color: 'var(--accent)', marginLeft: 10, cursor: 'pointer', fontWeight: 600 }} + Add PAT for 5,000 req/hr - + )} diff --git a/src/components/RateLimitBanner.test.jsx b/src/components/RateLimitBanner.test.jsx new file mode 100644 index 00000000..7c8128f0 --- /dev/null +++ b/src/components/RateLimitBanner.test.jsx @@ -0,0 +1,79 @@ +import React from 'react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' +import RateLimitBanner from './RateLimitBanner' + +const mockAppState = { + rateLimit: { remaining: 10, limit: 60, used: 50, reset: 1700000000 }, + pat: null, +} + +vi.mock('../context/AppContext', () => ({ + useApp: () => mockAppState, +})) + +function renderRateLimitBanner() { + return render( + + + + ) +} + +describe('RateLimitBanner', () => { + beforeEach(() => { + mockAppState.rateLimit = { remaining: 10, limit: 60, used: 50, reset: 1700000000 } + mockAppState.pat = null + }) + + it('renders nothing when rateLimit is null', () => { + mockAppState.rateLimit = null + const { container } = renderRateLimitBanner() + expect(container.firstChild).toBeNull() + }) + + it('renders nothing when rate limit is healthy (>20% and limit > 60)', () => { + mockAppState.rateLimit = { remaining: 5000, limit: 5000, used: 0, reset: 1700000000 } + const { container } = renderRateLimitBanner() + expect(container.firstChild).toBeNull() + }) + + it('renders banner when rate limit is low', () => { + renderRateLimitBanner() + expect(screen.getByText(/API RATE LIMIT:/i)).toBeInTheDocument() + expect(screen.getByText('10 / 60')).toBeInTheDocument() + }) + + it('renders "Add PAT" as a semantic Link pointing to /settings when pat is absent', () => { + renderRateLimitBanner() + const patLink = screen.getByRole('link', { name: /add pat for 5,000 req\/hr/i }) + expect(patLink).toBeInTheDocument() + expect(patLink).toHaveAttribute('href', '/settings') + }) + + it('allows keyboard focus navigation to the Add PAT link', async () => { + renderRateLimitBanner() + const patLink = screen.getByRole('link', { name: /add pat for 5,000 req\/hr/i }) + + expect(document.body).toHaveFocus() + await userEvent.tab() + expect(patLink).toHaveFocus() + }) + + it('does not render Add PAT link when pat is already provided', () => { + mockAppState.pat = 'ghp_testtoken' + renderRateLimitBanner() + expect(screen.queryByRole('link', { name: /add pat for 5,000 req\/hr/i })).not.toBeInTheDocument() + }) + + it('applies flex wrapping and minWidth: 0 to allow responsive shrinking without horizontal overflow', () => { + const { container } = renderRateLimitBanner() + const bannerContainer = container.firstChild + expect(bannerContainer).toHaveStyle({ display: 'flex', flexWrap: 'wrap' }) + + const messageSpan = bannerContainer.firstChild + expect(messageSpan).toHaveStyle({ display: 'flex', flexWrap: 'wrap', minWidth: '0px' }) + }) +}) diff --git a/src/components/ThemeToggle.jsx b/src/components/ThemeToggle.jsx index fa74fc8d..9bdf2119 100644 --- a/src/components/ThemeToggle.jsx +++ b/src/components/ThemeToggle.jsx @@ -1,12 +1,17 @@ import { FiSun, FiMoon } from "react-icons/fi"; import { useTheme } from "../context/ThemeContext"; -export default function ThemeToggle() { +export default function ThemeToggle({ onToggle }) { const { theme, toggleTheme } = useTheme(); + const handleClick = (e) => { + toggleTheme(e); + onToggle?.(e); + }; + return (