Files
daily_console_web/src/features/store-admin/Drawer.tsx
2026-09-17 17:57:21 +05:30

169 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}