Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
29 changes: 29 additions & 0 deletions src/App.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
53 changes: 48 additions & 5 deletions src/components/Navbar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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;
Comment thread
SamuelIVX marked this conversation as resolved.
Resolved
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 (
<header className="header">
<div className="header-inner">
<a href="#main" className="brand" aria-label="Samuel Hernandez Balderas, back to top"><span className="brand-at">@</span><span>samhb</span></a>
<a href="#main" ref={brandRef} className="brand" aria-label="Samuel Hernandez Balderas, back to top"><span className="brand-at">@</span><span>samhb</span></a>
<button className="menu-toggle" ref={menu} aria-expanded={menuOpen} aria-controls="navigation" onClick={() => setMenuOpen(!menuOpen)}>Menu</button>
<nav id="navigation" className={menuOpen ? 'nav open' : 'nav'} aria-label="Main navigation">
<nav id="navigation" ref={navRef} className={menuOpen ? 'nav open' : 'nav'} aria-label="Main navigation">
{sections.map(([id, label]) => <a key={id} href={`#${id}`} aria-current={active === id ? 'location' : undefined} onClick={() => setMenuOpen(false)}>{label}</a>)}
</nav>
<div className="header-controls">
<div className="header-controls" ref={controlsRef}>
<a href="https://www.linkedin.com/in/samuelhb/" className="icon-button" target="_blank" rel="noopener noreferrer" aria-label="LinkedIn" title="LinkedIn"><Icon name="linkedin" /></a>
<a href="https://github.com/SamuelIVX" className="icon-button" target="_blank" rel="noopener noreferrer" aria-label="GitHub" title="GitHub"><Icon name="github" /></a>
<button className="icon-button theme-toggle" onClick={switchTheme} aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} theme`} title="Switch theme"><Icon name={theme === 'light' ? 'moon' : 'sun'} /></button>
Expand Down
Loading
Loading