'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 { useEscape } from '../hooks/useEscape'; import { useScrollLock } from '../hooks/useScrollLock'; /** Public prop bag for {@link PixelSheet}. */ export interface PixelSheetProps { open: boolean; onOpenChange: (open: boolean) => void; side?: 'bottom' | 'top'; size?: 'sm' | 'md' | 'lg' | 'full'; dragHandle?: boolean; surface?: Surface; 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; children: React.ReactNode; } // Mobile bottom-sheet preset. Shares the bottom-sheet pattern slated for // PixelDrawer (Ola 4 will fold them together with snap points); for Ola 3 we // stand on PixelPortal + Ola 1 hooks directly and keep the API stable. const sizeMap = { bottom: { sm: 'h-1/4', md: 'h-1/2', lg: 'h-3/4', full: 'h-[100dvh]', }, top: { sm: 'h-1/4', md: 'h-1/2', lg: 'h-3/4', full: 'h-[100dvh]', }, } as const; export const PixelSheet = forwardRef(function PixelSheet( { open, onOpenChange, side = 'bottom', size = 'md', dragHandle = false, surface: surfaceProp, title, description, 'aria-label': ariaLabel, children, }, ref, ) { const surface = useEffectiveSurface(surfaceProp); const s = surfaceClasses(surface); const panelRef = useRef(null); const titleId = useId(); const descId = useId(); useFocusTrap(open, panelRef); useScrollLock(open); useEscape(() => 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( '[PixelSheet] role="dialog" has no accessible name. Pass either `title` or `aria-label`.', ); } if (!open) return null; const isBottom = side === 'bottom'; const sideAnchor = isBottom ? 'bottom-0 left-0 right-0' : 'top-0 left-0 right-0'; const enterFrom = isBottom ? 'translate-y-full' : '-translate-y-full'; void enterFrom; // kept for parity with future motion phase const setPanelRef = (node: HTMLDivElement | null) => { panelRef.current = node; if (typeof ref === 'function') ref(node); else if (ref) (ref as React.MutableRefObject).current = node; }; return (
{/* scrim */} onOpenChange(false)} /> {/* panel */}
{dragHandle && isBottom && ( )} {dragHandle && !isBottom && ( )} {(title || description) && (
{title && (

{title}

)} {description && (

{description}

)}
)}
{children}
); }); PixelSheet.displayName = 'PixelSheet';