Drawer
A full-height panel on the side where the page starts reading (right in Arabic), opened by your state, for menus and filters.
import { Drawer, DrawerProps, DrawerHeaderProps, DrawerBodyProps, DrawerFooterProps } from '@salla.sa/twilight-theme-engine/components/drawer';In plain words
A drawer is a panel that covers one side of the screen from top to bottom, like the menu behind the ☰ button on a phone. It works like Modal: your component keeps isOpen in state and passes onClose, which the drawer calls when the shopper clicks outside it or presses Escape.
You do not choose the side. On right-to-left pages (Arabic) it opens on the right, on left-to-right pages on the left: the side where reading starts.
Drawer.Header, Drawer.Body and Drawer.Footer are the sections inside. When the content is long only the body scrolls, and the header and footer stay where they are.
Signature
function Drawer(props: DrawerProps): React.ReactPortal | null
interface DrawerProps {
isOpen: boolean;
onClose: () => void;
children: ReactNode;
size?: 'sm' | 'md' | 'lg' | 'full'; // default 'md' (w-80, 20rem)
closeOnBackdropClick?: boolean; // default true
closeOnEscape?: boolean; // default true
preventBodyScroll?: boolean; // default true: adds class menu-opened to <body>
className?: string; // added to the panel
}
// No position prop: the side follows useTwilight().theme.is_rtl.
Drawer.Header(props: DrawerHeaderProps) // <h2> title + close button
Drawer.Body(props: DrawerBodyProps) // flex-1, scrolls
Drawer.Footer(props: DrawerFooterProps)
interface DrawerHeaderProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
showCloseButton?: boolean; // default true, but the button needs onClose
onClose?: () => void;
}
interface DrawerBodyProps extends HTMLAttributes<HTMLDivElement> { children: ReactNode }
interface DrawerFooterProps extends HTMLAttributes<HTMLDivElement> { children: ReactNode }Try it live
import { useCallback, useState } from 'react';
import { Drawer } from '@salla.sa/twilight-theme-engine/components/drawer';
export function FiltersDrawer() {
const [isOpen, setIsOpen] = useState(false);
const close = useCallback(() => setIsOpen(false), []);
return (
<>
<button type="button" className="btn btn--primary" onClick={() => setIsOpen(true)}>
Filters
</button>
<Drawer isOpen={isOpen} onClose={close}>
<Drawer.Header onClose={close}>Filters</Drawer.Header>
<Drawer.Body>{/* filter controls */}</Drawer.Body>
<Drawer.Footer>
<button type="button" className="btn btn--primary w-full" onClick={close}>
Show results
</button>
</Drawer.Footer>
</Drawer>
</>
);
}
Example
import { useCallback, useState, type ReactNode } from 'react';
import { Drawer } from '@salla.sa/twilight-theme-engine/components/drawer';
export function FiltersDrawer({ children }: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
// Stable: Drawer re-runs its focus effect whenever onClose is a new function.
const close = useCallback(() => setIsOpen(false), []);
return (
<>
<button type="button" className="btn btn--outline-primary" onClick={() => setIsOpen(true)}>
Filters
</button>
<Drawer isOpen={isOpen} onClose={close} size="md">
<Drawer.Header onClose={close}>Filters</Drawer.Header>
<Drawer.Body>{children}</Drawer.Body>
<Drawer.Footer>
<button type="button" className="btn btn--primary w-full" onClick={close}>
Show results
</button>
</Drawer.Footer>
</Drawer>
</>
);
}
How it behaves
The side is derived, not configured:
useTwilight().theme.is_rtlgivesright-0when true andleft-0otherwise. The store settings answeris_rtlper request language, so Arabic pages open on the right.It has the same lifecycle as Modal: renders nothing on the server or while closed, portals into
document.bodywhen open, focuses the panel, addsmenu-openedto<body>and listens for Escape. There is no focus trap.Unlike Modal, the Enter and Space handler sits on the backdrop, a sibling of the panel, so typing and pressing buttons inside a drawer is safe.
The panel is
fixed top-0 bottom-0 flex flex-colandDrawer.Bodyisflex-1 overflow-y-auto, which is why the body alone scrolls. Sizes:smw-64 (16rem),mdw-80 (20rem),lgw-96 (24rem),fullw-full. The wrapper isfixed inset-0 z-[200].There is no slide-in animation: closed returns
null, so the panel is always rendered attranslate-x-0.The engine uses it for the mobile menu (
MobileMenu, withpreventBodyScroll={false}because the menu locks scrolling itself) and the product-listing filters on small screens. The same export is also at@salla.sa/twilight-theme-engine/drawer.
Gotchas
There is no
positionprop. The example in the engine'sdrawer/index.tspassesposition="left", which fails type checking and would do nothing. The side follows the page direction.It calls
useTwilight(), so rendering it outsideTwilightProviderthrowsuseTwilight must be used within a TwilightProvider(Modal, Collapse and Dropdown do not need the provider).The effect code is the same as Modal's, so an inline
onClosehas the same cost: while open, every re-render of the owner moves focus back to the panel (an input inside loses focus as you type); while closed, every re-render removesmenu-openedfrom<body>and refocuses the last opener. WraponCloseinuseCallback.Scroll locking needs a theme CSS rule for
body.menu-opened(the reference theme has one inapp/styles/app.css); the engine only toggles the class.
Related
A dialog box over a dimmed page, opened and closed by your own state, with Header, Body and Footer parts.
useThemeReads the merchant's theme colors, font, theme settings and whether the page reads right-to-left.
useTwilightReads everything TwilightProvider knows: store, theme, settings, language, direction, current page, login token and the Salla SDK.