169 lines
6.1 KiB
TypeScript
169 lines
6.1 KiB
TypeScript
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 (
|
||
<div className="drawer-root" role="dialog" aria-modal="true" aria-label={title}>
|
||
<button type="button" className="drawer-scrim" aria-label="Close" onClick={onClose} />
|
||
|
||
<div className="drawer-panel dialog-panel" style={{ width }}>
|
||
<header className="drawer-head">
|
||
<div className="drawer-head-text">
|
||
{/* `title` on both, because the header is one row now and a long
|
||
product name or a branch list is clipped to fit it. The full
|
||
text stays reachable on hover; it is also, always, on the row
|
||
the drawer was opened from. */}
|
||
<h2
|
||
className="drawer-title"
|
||
title={title}
|
||
{...(isTitleMono ? { 'data-mono': 'true' } : {})}
|
||
>
|
||
{title}
|
||
</h2>
|
||
{subtitle ? (
|
||
<span className="drawer-sub" title={subtitle}>
|
||
{subtitle}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
|
||
{/* Beside the title, not beneath it. Stacked under the subtitle it was
|
||
a third line, and a third line is what made this header twice the
|
||
height of the nav bar it now matches.
|
||
|
||
The badge here is the same badge the table row wears, in the same
|
||
colour — see the note on `Badge` in `drawerKit`. */}
|
||
{meta ? <div className="drawer-meta">{meta}</div> : null}
|
||
<button type="button" className="drawer-close" aria-label="Close" onClick={onClose}>
|
||
<X size={16} />
|
||
</button>
|
||
</header>
|
||
|
||
<div className="drawer-body" {...(isBare ? { 'data-bare': 'true' } : {})}>
|
||
{children}
|
||
</div>
|
||
|
||
{footer ? (
|
||
<footer
|
||
className="drawer-foot"
|
||
{...(isFooterFilled ? { 'data-fill': 'true' } : {})}
|
||
{...(isFooterSpread ? { 'data-spread': 'true' } : {})}
|
||
>
|
||
{footer}
|
||
</footer>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|