drawer and upload

This commit is contained in:
2026-09-07 13:02:35 +05:30
parent 8defbd418f
commit 05ac76f5f0
22 changed files with 3194 additions and 1568 deletions

View File

@@ -1,35 +1,51 @@
import { useEffect, type ReactNode } from 'react';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { X } from 'lucide-react';
import './drawer.css';
/**
* The side drawer, matched to `OrderDetailDrawer`.
* The console's side drawer. Every drawer in the product is this one.
*
* That one is the site's drawer and every measurement here is taken from it:
* the flex-end wrapper at z-index 70, a full-bleed scrim BUTTON rather than a
* div (so it is reachable and announces itself), a 460px panel capped at 92vw,
* `-24px 0 56px -12px` of shadow, a STICKY header on a hairline, and a 30px
* rounded close button on `--color-surface-sunken`.
* ── Why the panel is a flex column ──────────────────────────────────────────
*
* Extracted rather than copied a third time. The drawers I added had drifted —
* different scrim opacity, different shadow, a bare `X`, a header that scrolled
* away — which is exactly how two screens stop looking like one product.
* 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.
*/
export interface DrawerProps {
title: string;
/** One line under the title. */
/** One line under the title — a brand, an SKU, a store name. */
subtitle?: string;
/** Sits beside the title — a status chip, usually. */
titleAside?: ReactNode;
/** 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. 460 is the site default; only widen for content that
* genuinely cannot fold, such as a grid of product cards.
* 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;
}
@@ -37,12 +53,23 @@ export interface DrawerProps {
export function Drawer({
title,
subtitle,
titleAside,
width = 460,
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();
@@ -52,83 +79,36 @@ export function Drawer({
}, [onClose]);
return (
<div
role="dialog"
aria-modal="true"
aria-label={title}
style={{ position: 'fixed', inset: 0, zIndex: 70, display: 'flex', justifyContent: 'flex-end' }}
>
<button
type="button"
aria-label="Close"
onClick={onClose}
style={{
position: 'absolute',
inset: 0,
border: 0,
background: 'rgb(15 23 42 / .32)',
cursor: 'pointer',
}}
/>
<div
className="dialog-panel"
style={{
position: 'relative',
width,
maxWidth: '92vw',
height: '100%',
overflowY: 'auto',
background: 'var(--color-surface)',
borderLeft: '1px solid var(--color-line)',
boxShadow: '-24px 0 56px -12px rgb(15 23 42 / .18)',
}}
>
<HStack
justify="between"
align="start"
gap={1}
style={{
position: 'sticky',
top: 0,
zIndex: 1,
padding: 20,
background: 'var(--color-surface)',
borderBottom: '1px solid var(--color-line)',
}}
>
<VStack gap={0.5} style={{ minWidth: 0 }}>
<Text type="label" size="lg" weight="semibold">
{title}
</Text>
{subtitle ? (
<Text type="body" size="sm" color="secondary">
{subtitle}
</Text>
) : null}
{titleAside}
</VStack>
<button
type="button"
aria-label="Close"
onClick={onClose}
style={{
width: 30,
height: 30,
borderRadius: 10,
border: 0,
flex: 'none',
display: 'grid',
placeItems: 'center',
background: 'var(--color-surface-sunken)',
color: 'var(--color-ink-3)',
cursor: 'pointer',
}}
>
<X size={15} />
</button>
</HStack>
<div className="drawer-root" role="dialog" aria-modal="true" aria-label={title}>
<button type="button" className="drawer-scrim" aria-label="Close" onClick={onClose} />
{isBare ? children : <VStack gap={3} padding={2}>{children}</VStack>}
<div className="drawer-panel dialog-panel" style={{ width }}>
<header className="drawer-head">
<div className="drawer-head-text">
<h2 className="drawer-title" {...(isTitleMono ? { 'data-mono': 'true' } : {})}>
{title}
</h2>
{subtitle ? <span className="drawer-sub">{subtitle}</span> : null}
{meta ? <div className="drawer-meta">{meta}</div> : null}
</div>
<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>
);