cmux/web/app/components/mobile-drawer.tsx
Lawrence Chen d1dd2e6535 Full-screen overlay with drawer-local close button and theme toggle
- Overlay covers entire viewport (inset-0 z-40) including header
- Drawer at z-50 spans full height with its own header row
- Duplicate theme toggle + close (X) button inside drawer
- Docs sidebar/FAB bumped to z-50 to stay above overlay
2026-02-10 00:54:51 -08:00

126 lines
3.2 KiB
TypeScript

"use client";
import { useState, useEffect, useRef, useCallback } from "react";
export function useMobileDrawer() {
const [open, setOpen] = useState(false);
const drawerRef = useRef<HTMLElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const close = useCallback(() => {
setOpen(false);
buttonRef.current?.focus();
}, []);
const toggle = useCallback(() => setOpen((v) => !v), []);
// Close on Escape
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, close]);
// Trap focus
useEffect(() => {
if (!open || !drawerRef.current) return;
const el = drawerRef.current;
const focusable = el.querySelectorAll<HTMLElement>(
'a[href], button, [tabindex]:not([tabindex="-1"])'
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const trap = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
el.addEventListener("keydown", trap);
return () => el.removeEventListener("keydown", trap);
}, [open]);
// Lock body scroll on mobile
useEffect(() => {
if (!open) return;
const mq = window.matchMedia("(min-width: 768px)");
if (mq.matches) return;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "";
};
}, [open]);
return { open, setOpen, toggle, close, drawerRef, buttonRef };
}
export function MobileDrawerOverlay({ open, onClose }: { open: boolean; onClose: () => void }) {
if (!open) return null;
return (
<div
className="fixed inset-0 z-40 bg-black/50 md:hidden"
aria-hidden="true"
onClick={onClose}
/>
);
}
/** Hamburger / X toggle button (only visible on mobile) */
export function MobileDrawerToggle({
open,
onClick,
buttonRef,
className,
}: {
open: boolean;
onClick: () => void;
buttonRef: React.RefObject<HTMLButtonElement | null>;
className?: string;
}) {
return (
<button
ref={buttonRef}
onClick={onClick}
aria-expanded={open}
className={
className ??
"md:hidden w-8 h-8 flex items-center justify-center text-muted hover:text-foreground transition-colors"
}
aria-label={open ? "Close menu" : "Open menu"}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{open ? (
<path d="M18 6L6 18M6 6l12 12" />
) : (
<>
<path d="M3 6h18" />
<path d="M3 12h18" />
<path d="M3 18h18" />
</>
)}
</svg>
</button>
);
}