'use client'; import React, { forwardRef, useId, useRef } from 'react'; import { Surface, cn, surfaceClasses, useEffectiveSurface, } from '../common'; import { PixelPortal } from '../overlay-foundation/PixelPortal'; import { OverlayBackdrop } from './_internal/OverlayBackdrop'; import { useFocusTrap } from '../hooks/useFocusTrap'; import { useScrollLock } from '../hooks/useScrollLock'; import { useEscape } from '../hooks/useEscape'; type DrawerSide = 'right' | 'left' | 'top' | 'bottom'; type DrawerSize = 'sm' | 'md' | 'lg' | 'xl' | 'full'; export interface PixelDrawerProps { open: boolean; onOpenChange: (open: boolean) => void; side?: DrawerSide; size?: DrawerSize; overlay?: boolean; dismissOnOverlay?: boolean; trapFocus?: boolean; title?: string; description?: string; /** * Accessible name fallback when `title` is omitted. WCAG 4.1.2 requires * every `role="dialog"` to expose a name; supply `title` OR `aria-label`. */ 'aria-label'?: string; surface?: Surface; container?: HTMLElement | null; children: React.ReactNode; } const sideWidth: Record = { sm: 'w-[280px]', md: 'w-[360px]', lg: 'w-[480px]', xl: 'w-[640px]', full: 'w-screen', }; const sideHeight: Record = { sm: 'h-[200px]', md: 'h-[320px]', lg: 'h-[440px]', xl: 'h-[560px]', full: 'h-screen', }; function sidePositionClasses(side: DrawerSide, size: DrawerSize): string { switch (side) { case 'right': return cn( 'right-0 top-0 h-full max-w-full', sideWidth[size], 'translate-x-0 motion-safe:animate-[pxl-drawer-in-right_180ms_ease-out]', ); case 'left': return cn( 'left-0 top-0 h-full max-w-full', sideWidth[size], 'translate-x-0 motion-safe:animate-[pxl-drawer-in-left_180ms_ease-out]', ); case 'top': return cn( 'top-0 left-0 w-full max-h-full', sideHeight[size], 'translate-y-0 motion-safe:animate-[pxl-drawer-in-top_180ms_ease-out]', ); case 'bottom': return cn( 'bottom-0 left-0 w-full max-h-full', sideHeight[size], 'translate-y-0 motion-safe:animate-[pxl-drawer-in-bottom_180ms_ease-out]', ); } } type DrawerComponent = React.ForwardRefExoticComponent< PixelDrawerProps & React.RefAttributes > & { Header: typeof PixelDrawerHeader; Body: typeof PixelDrawerBody; Footer: typeof PixelDrawerFooter; }; const PixelDrawerRoot = forwardRef( function PixelDrawer( { open, onOpenChange, side = 'right', size = 'md', overlay = true, dismissOnOverlay = true, trapFocus = true, title, description, 'aria-label': ariaLabel, surface: surfaceProp, container, children, }, forwardedRef, ) { const surface = useEffectiveSurface(surfaceProp); const s = surfaceClasses(surface); const titleId = useId(); const descId = useId(); const panelRef = useRef(null); useFocusTrap(open && trapFocus, panelRef); useScrollLock(open); useEscape(() => { if (open) onOpenChange(false); }, open); // Dev-only WCAG 4.1.2 guard: every role=dialog needs an accessible name. if ( process.env.NODE_ENV !== 'production' && open && !title && !ariaLabel && typeof console !== 'undefined' ) { console.warn( '[PixelDrawer] role="dialog" has no accessible name. Pass either `title` or `aria-label`.', ); } if (!open) return null; const setRefs = (node: HTMLDivElement | null) => { panelRef.current = node; if (typeof forwardedRef === 'function') forwardedRef(node); else if (forwardedRef && typeof forwardedRef === 'object') { (forwardedRef as React.MutableRefObject).current = node; } }; return (
{overlay && ( { if (dismissOnOverlay) onOpenChange(false); }} /> )}
{(title || description) && (
{title && {title}} {description && {description}}
)} {children}
); }, ); PixelDrawerRoot.displayName = 'PixelDrawer'; /* ────────────────────────────────────────────────────────────────────────── PixelDrawer.Header ────────────────────────────────────────────────────────────────────────── */ export interface PixelDrawerHeaderProps extends React.HTMLAttributes { surface?: Surface; } const PixelDrawerHeader = forwardRef( function PixelDrawerHeader( { className, children, surface: surfaceProp, ...rest }, ref, ) { const surface = useEffectiveSurface(surfaceProp); const dividerClass = surface === 'pixel' ? 'border-b-2 border-retro-border' : 'border-b border-retro-border'; return (
{children}
); }, ); PixelDrawerHeader.displayName = 'PixelDrawer.Header'; /* ────────────────────────────────────────────────────────────────────────── PixelDrawer.Body ────────────────────────────────────────────────────────────────────────── */ export interface PixelDrawerBodyProps extends React.HTMLAttributes {} const PixelDrawerBody = forwardRef( function PixelDrawerBody({ className, children, ...rest }, ref) { return (
{children}
); }, ); PixelDrawerBody.displayName = 'PixelDrawer.Body'; /* ────────────────────────────────────────────────────────────────────────── PixelDrawer.Footer ────────────────────────────────────────────────────────────────────────── */ export interface PixelDrawerFooterProps extends React.HTMLAttributes { surface?: Surface; } const PixelDrawerFooter = forwardRef( function PixelDrawerFooter( { className, children, surface: surfaceProp, ...rest }, ref, ) { const surface = useEffectiveSurface(surfaceProp); const dividerClass = surface === 'pixel' ? 'border-t-2 border-retro-border' : 'border-t border-retro-border'; return (
{children}
); }, ); PixelDrawerFooter.displayName = 'PixelDrawer.Footer'; export const PixelDrawer = PixelDrawerRoot as DrawerComponent; PixelDrawer.Header = PixelDrawerHeader; PixelDrawer.Body = PixelDrawerBody; PixelDrawer.Footer = PixelDrawerFooter;