import { useEffect, type ReactNode } from 'react'; import { X } from 'lucide-react'; import './drawer.css'; /** * The console's side drawer. Every drawer in the product is this one. * * ── Why the panel is a flex column ────────────────────────────────────────── * * It used to be a single scrolling box with a sticky header. That cannot hold a * footer: an action bar inside a scrolling container scrolls away with the * content, which is exactly wrong for the button you opened the drawer to * press. Header, scrolling body and action bar are now three rows of a column, * so only the middle one moves. * * ── What belongs here and what does not ───────────────────────────────────── * * The shell owns the chrome — scrim, panel, header, close, footer — and nothing * else. Content comes from `drawerKit`. A drawer that styles its own card or * badge is the beginning of the drift this component exists to end. */ /** * How many drawers are open right now. * * Module scope rather than component state, because the count has to be shared * between separate instances that know nothing about each other — see the * scroll lock in the component below. */ let openDrawers = 0; export interface DrawerProps { title: string; /** One line under the title — a brand, an SKU, a store name. */ subtitle?: string; /** Renders the title in the mono face. For codes: order ids, SKUs. */ isTitleMono?: boolean; /** The status pill and any metadata beside it, on the row under the title. */ meta?: ReactNode; /** * Panel width in px. The design system's range is 480–560; 520 is the * default and only content that genuinely cannot fold should exceed it. */ width?: number; /** Renders the body unpadded, for panels that manage their own layout. */ isBare?: boolean; /** * The fixed action bar. Omit it entirely for a read-only drawer — an empty * bar is a band of chrome that costs height and offers nothing. */ footer?: ReactNode; /** Stretch footer actions to fill the width, for a single primary action. */ isFooterFilled?: boolean; /** * Push the first action to the left and the rest right — a destructive * action kept away from the one people mean to press. */ isFooterSpread?: boolean; onClose: () => void; children: ReactNode; } export function Drawer({ title, subtitle, isTitleMono, meta, width = 520, isBare, footer, isFooterFilled, isFooterSpread, onClose, children, }: DrawerProps) { /* Escape closes. A dialog dismissible only by finding the scrim with a mouse is not dismissible at all for anyone on a keyboard, and this one sits over the list the operator is working through. */ useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [onClose]); /* The page behind stops scrolling while this is open. `overscroll-behavior: contain` on the body already stops a scroll that reaches the end of the drawer from continuing into the page underneath. What it does not stop is a wheel over the SCRIM, which is most of the screen — so the list behind would scroll away under a drawer that stayed put, and closing it left the operator somewhere they had not chosen to be. No padding is added to compensate for the vanished scrollbar: `html` carries `scrollbar-gutter: stable`, so the gutter is reserved whether or not a bar is drawn there and the page does not jump sideways as this opens. */ useEffect(() => { /* Counted, not a boolean. Drawers stack — the partner's rider list opens a rider drawer on top of itself — and closing the inner one must not unlock the page while the outer one is still covering it. */ openDrawers += 1; document.body.style.overflow = 'hidden'; return () => { openDrawers -= 1; if (openDrawers === 0) document.body.style.overflow = ''; }; }, []); return (