spb/airiskindex Public
The most methodologically rigorous, fully transparent AI job-exposure index.
TypeScript 88%
Python 6.1%
SQL 2.7%
CSS 1.2%
JavaScript 0.9%
Shell 0.8%
1"use client";23// File: mobile-menu.tsx4// Path: apps/web/components/mobile-menu.tsx5// Project: AI Risk Index — airiskindex.io6// Author: Simon-Pierre Boucher7// Contact: contact@spboucher.ai8// Copyright © 2026 Simon-Pierre Boucher. All rights reserved.9//10// Description: Mobile right-side drawer menu (portal, scroll lock, Escape/overlay close).111213import Link from "next/link";14import { useEffect, useState } from "react";15import { createPortal } from "react-dom";16import { LogoMark } from "./logo";1718export function MobileMenu({ items }: { items: ReadonlyArray<readonly [string, string]> }): JSX.Element {19 const [open, setOpen] = useState(false);2021 // Close on Escape; lock scroll while open.22 useEffect(() => {23 if (!open) return;24 const onKey = (event: KeyboardEvent) => {25 if (event.key === "Escape") setOpen(false);26 };27 document.addEventListener("keydown", onKey);28 document.body.style.overflow = "hidden";29 return () => {30 document.removeEventListener("keydown", onKey);31 document.body.style.overflow = "";32 };33 }, [open]);3435 return (36 <div className="sm:hidden">37 <button38 type="button"39 aria-expanded={open}40 aria-label={open ? "Close menu" : "Open menu"}41 onClick={() => setOpen((value) => !value)}42 className="flex h-9 w-9 items-center justify-center rounded-lg border border-[var(--border)] bg-[var(--surface-1)] text-[var(--ink)]"43 >44 {open ? (45 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">46 <path d="M3 3l10 10M13 3L3 13" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />47 </svg>48 ) : (49 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">50 <path d="M2 4.5h12M2 8h12M2 11.5h12" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />51 </svg>52 )}53 </button>5455 {/* Portal to <body>: the blurred sticky header is a containing block for56 fixed descendants, which would trap and stretch the drawer. */}57 {open &&58 createPortal(59 <>60 <button61 type="button"62 aria-label="Close menu"63 onClick={() => setOpen(false)}64 className="fixed inset-0 z-40 cursor-default bg-black/25 backdrop-blur-[2px]"65 />66 {/* Drawer sliding in from the right */}67 <nav className="fixed right-0 top-0 z-50 flex h-dvh w-72 max-w-[85vw] animate-[drawer-in_180ms_ease-out] flex-col border-l border-[var(--border)] bg-[var(--surface-1)] shadow-2xl">68 <div className="flex items-center justify-between border-b border-[var(--grid)] px-5 py-3.5">69 <span className="flex items-center gap-2.5 font-semibold tracking-tight">70 <LogoMark size={20} />71 AI Risk Index72 </span>73 <button74 type="button"75 aria-label="Close menu"76 onClick={() => setOpen(false)}77 className="flex h-8 w-8 items-center justify-center rounded-lg border border-[var(--border)] text-[var(--ink)]"78 >79 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">80 <path d="M3 3l10 10M13 3L3 13" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />81 </svg>82 </button>83 </div>84 <ul className="flex-1 overflow-y-auto px-5 py-2">85 {items.map(([href, label]) => (86 <li key={href} className="border-b border-[var(--grid)] last:border-b-0">87 <Link88 href={href}89 onClick={() => setOpen(false)}90 className="block py-3.5 text-base font-medium text-[var(--ink)] active:bg-[var(--wash)]"91 >92 {label}93 </Link>94 </li>95 ))}96 </ul>97 <p className="border-t border-[var(--grid)] px-5 py-4 text-xs text-[var(--muted)]">98 <a href="mailto:contact@spboucher.ai" className="hover:underline">99 contact@spboucher.ai100 </a>101 </p>102 </nav>103 </>,104 document.body,105 )}106 </div>107 );108}109