diff --git a/src/App.jsx b/src/App.jsx index 58e6656..e00d493 100755 --- a/src/App.jsx +++ b/src/App.jsx @@ -41,7 +41,7 @@ const App = () => { clearTimeout(fallbackTimer.current); document.documentElement.classList.add('theme-fallback'); update(); - fallbackTimer.current = setTimeout(() => document.documentElement.classList.remove('theme-fallback'), 1600); + fallbackTimer.current = setTimeout(() => document.documentElement.classList.remove('theme-fallback'), 350); return; } diff --git a/src/App.test.jsx b/src/App.test.jsx index 9ea94eb..5e8c1e1 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -4,6 +4,7 @@ * avoid WebGL under jsdom. */ import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { axe } from "jest-axe"; import { describe, expect, it, vi } from "vitest"; import App from "./App"; @@ -48,6 +49,34 @@ describe("App", () => { expect(mailto).toHaveAttribute("href", "mailto:samuel05.hb@gmail.com"); }); + it("marks outside elements inert when mobile navigation menu is open", async () => { + const { container } = renderApp(); + const menuToggle = screen.getByRole("button", { name: /menu/i }); + const main = container.querySelector("#main"); + const footer = container.querySelector("footer"); + const headerControls = container.querySelector(".header-controls"); + const brand = container.querySelector(".brand"); + + expect(main).not.toHaveAttribute("inert"); + expect(footer).not.toHaveAttribute("inert"); + expect(headerControls).not.toHaveAttribute("inert"); + expect(brand).not.toHaveAttribute("inert"); + + await userEvent.click(menuToggle); + + expect(main).toHaveAttribute("inert"); + expect(footer).toHaveAttribute("inert"); + expect(headerControls).toHaveAttribute("inert"); + expect(brand).toHaveAttribute("inert"); + + await userEvent.click(menuToggle); + + expect(main).not.toHaveAttribute("inert"); + expect(footer).not.toHaveAttribute("inert"); + expect(headerControls).not.toHaveAttribute("inert"); + expect(brand).not.toHaveAttribute("inert"); + }); + it("has no accessibility violations on initial render", async () => { const { container } = renderApp(); const results = await axe(container); diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx index 696ee8c..3a0ad31 100755 --- a/src/components/Navbar.jsx +++ b/src/components/Navbar.jsx @@ -10,6 +10,9 @@ export default function Navbar({ theme, switchTheme }) { const [menuOpen, setMenuOpen] = useState(false); const [active, setActive] = useState(''); const menu = useRef(null); + const navRef = useRef(null); + const brandRef = useRef(null); + const controlsRef = useRef(null); useEffect(() => { @@ -39,25 +42,65 @@ export default function Navbar({ theme, switchTheme }) { }, [active]); useEffect(() => { + const outsideElements = [ + document.getElementById('main'), + document.querySelector('footer'), + document.querySelector('.skip-link'), + brandRef.current, + controlsRef.current, + ].filter(Boolean); + + outsideElements.forEach(el => { + if (menuOpen) el.setAttribute('inert', ''); + else el.removeAttribute('inert'); + }); + + if (!menuOpen) return; const keydown = event => { - if (event.key === 'Escape' && menuOpen) { + if (event.key === 'Escape') { setMenuOpen(false); menu.current?.focus(); + return; + } + if (event.key === 'Tab') { + const focusable = [menu.current, ...(navRef.current?.querySelectorAll('a') || [])].filter(Boolean); + if (!focusable.length) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (!focusable.includes(document.activeElement)) { + event.preventDefault(); + if (event.shiftKey) { + last.focus(); + } else { + first.focus(); + } + return; + } + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } } }; document.addEventListener('keydown', keydown); - return () => document.removeEventListener('keydown', keydown); + return () => { + document.removeEventListener('keydown', keydown); + outsideElements.forEach(el => el.removeAttribute('inert')); + }; }, [menuOpen]); return (
- @samhb + @samhb -