186 lines
4.9 KiB
TypeScript
186 lines
4.9 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
useEffect,
|
|
useRef,
|
|
type CSSProperties,
|
|
type KeyboardEvent,
|
|
type MouseEvent,
|
|
type ReactNode,
|
|
} from "react";
|
|
|
|
export interface DrawerProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
side: "right" | "bottom";
|
|
/** CSS width string — default "360px" for right, "100%" for bottom */
|
|
width?: string;
|
|
children: ReactNode;
|
|
}
|
|
|
|
const FOCUSABLE_SELECTORS =
|
|
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
|
|
/**
|
|
* Drawer — accessible slide-in overlay panel.
|
|
* - side="right" → right side panel (default 360px width)
|
|
* - side="bottom" → bottom-sheet (full width, mobile-first)
|
|
* Click outside / Escape → onClose.
|
|
* Focus trap: Tab cycles within open drawer.
|
|
*/
|
|
export function Drawer({ open, onClose, side, width, children }: DrawerProps) {
|
|
const drawerRef = useRef<HTMLDivElement>(null);
|
|
const previousFocusRef = useRef<Element | null>(null);
|
|
|
|
// Save focus and restore on close
|
|
useEffect(() => {
|
|
if (open) {
|
|
previousFocusRef.current = document.activeElement;
|
|
// Move focus into drawer on next frame
|
|
const id = requestAnimationFrame(() => {
|
|
const el =
|
|
drawerRef.current?.querySelector<HTMLElement>(FOCUSABLE_SELECTORS);
|
|
el?.focus();
|
|
});
|
|
return () => cancelAnimationFrame(id);
|
|
} else {
|
|
if (previousFocusRef.current instanceof HTMLElement) {
|
|
previousFocusRef.current.focus();
|
|
}
|
|
}
|
|
}, [open]);
|
|
|
|
// Escape key
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const handleKeyDown = (e: globalThis.KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
onClose();
|
|
}
|
|
};
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
}, [open, onClose]);
|
|
|
|
// Prevent body scroll while open
|
|
useEffect(() => {
|
|
if (open) {
|
|
const prev = document.body.style.overflow;
|
|
document.body.style.overflow = "hidden";
|
|
return () => {
|
|
document.body.style.overflow = prev;
|
|
};
|
|
}
|
|
}, [open]);
|
|
|
|
const handleOverlayClick = (e: MouseEvent<HTMLDivElement>) => {
|
|
if (e.target === e.currentTarget) {
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
const handleDrawerKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
if (e.key !== "Tab") return;
|
|
const drawer = drawerRef.current;
|
|
if (!drawer) return;
|
|
const focusable = Array.from(
|
|
drawer.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS),
|
|
).filter((el) => !el.closest("[aria-hidden]"));
|
|
if (focusable.length === 0) return;
|
|
const first = focusable[0] as HTMLElement;
|
|
const last = focusable[focusable.length - 1] as HTMLElement;
|
|
if (e.shiftKey) {
|
|
if (document.activeElement === first) {
|
|
e.preventDefault();
|
|
last.focus();
|
|
}
|
|
} else {
|
|
if (document.activeElement === last) {
|
|
e.preventDefault();
|
|
first.focus();
|
|
}
|
|
}
|
|
};
|
|
|
|
const defaultWidth = side === "right" ? "360px" : "100%";
|
|
const resolvedWidth = width ?? defaultWidth;
|
|
|
|
const panelStyle: CSSProperties =
|
|
side === "right"
|
|
? {
|
|
position: "fixed",
|
|
top: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
width: resolvedWidth,
|
|
maxWidth: "100vw",
|
|
background: "var(--bg-card)",
|
|
boxShadow: "0 0 32px rgba(0,0,0,0.18)",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
transform: open ? "translateX(0)" : "translateX(100%)",
|
|
transition: "transform 180ms ease",
|
|
zIndex: 200,
|
|
overflowY: "auto",
|
|
}
|
|
: {
|
|
position: "fixed",
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
width: "100%",
|
|
maxHeight: "85vh",
|
|
background: "var(--bg-card)",
|
|
boxShadow: "0 -4px 32px rgba(0,0,0,0.14)",
|
|
borderRadius: "12px 12px 0 0",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
transform: open ? "translateY(0)" : "translateY(100%)",
|
|
transition: "transform 180ms ease",
|
|
zIndex: 200,
|
|
overflowY: "auto",
|
|
};
|
|
|
|
if (!open) {
|
|
// Keep in DOM for transition but aria-hide
|
|
return (
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
position: "fixed",
|
|
inset: 0,
|
|
pointerEvents: "none",
|
|
zIndex: 199,
|
|
}}
|
|
>
|
|
<div ref={drawerRef} style={panelStyle} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
onClick={handleOverlayClick}
|
|
style={{
|
|
position: "fixed",
|
|
inset: 0,
|
|
background: "rgba(0,0,0,0.32)",
|
|
zIndex: 199,
|
|
display: "flex",
|
|
alignItems: side === "bottom" ? "flex-end" : "stretch",
|
|
justifyContent: side === "right" ? "flex-end" : "stretch",
|
|
}}
|
|
>
|
|
<div
|
|
ref={drawerRef}
|
|
style={panelStyle}
|
|
onKeyDown={handleDrawerKeyDown}
|
|
tabIndex={-1}
|
|
>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|