gendesign/frontend/src/components/ui/Drawer.tsx
lekss361 2548f71f40
Some checks failed
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Failing after 1m6s
Deploy / deploy (push) Has been skipped
feat(sf-fe-a3): UI primitives — HeadlineBar + Badge + Drawer (#341)
2026-05-17 22:06:53 +00:00

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>
);
}