@@ -7,14 +7,39 @@ import React from 'react';
77import { useIsMobile } from '../hooks/useIsMobile' ;
88import { Button } from './Button' ;
99
10+ /**
11+ * SideSheet - A slide-in panel component that can appear from the left or right side.
12+ *
13+ * Supports both controlled and uncontrolled modes:
14+ * - Controlled: Provide both `open` and `onOpenChange` props. Parent manages state.
15+ * - Uncontrolled: Omit `open` prop. Component manages its own state internally.
16+ */
1017export function SideSheet (
1118 props : {
19+ /** Which side the sheet slides in from */
1220 side : 'left' | 'right' ;
13- open ?: boolean ;
21+ /**
22+ * Optional CSS class to monitor and sync with `document.body.classList`.
23+ * When set, a MutationObserver watches for the class and syncs the sheet state accordingly.
24+ * Adding this class opens the sheet, removing it closes it.
25+ * Works in both controlled and uncontrolled modes.
26+ */
1427 toggleClass ?: string ;
28+ /**
29+ * Modal behavior: true (always modal), false (never modal), or 'mobile' (modal only on mobile).
30+ * Defaults to 'mobile'.
31+ */
1532 modal ?: true | false | 'mobile' ;
16- onClose ?: ( ) => void ;
17- withShim ?: boolean ;
33+ /**
34+ * Controls visibility. If provided, component is controlled (parent manages state).
35+ * If undefined, component is uncontrolled (manages its own state).
36+ */
37+ open ?: boolean ;
38+ /** Called when the open state changes. Receives the new state (true/false). Only used in controlled mode. */
39+ onOpenChange ?: ( open : boolean ) => void ;
40+ /** Show a backdrop overlay when modal */
41+ withScrim ?: boolean ;
42+ /** Show a close button when modal */
1843 withCloseButton ?: boolean ;
1944 } & React . HTMLAttributes < HTMLDivElement >
2045) {
@@ -25,33 +50,35 @@ export function SideSheet(
2550 toggleClass,
2651 open : openState ,
2752 modal = 'mobile' ,
28- withShim ,
53+ withScrim ,
2954 withCloseButton,
30- onClose ,
55+ onOpenChange ,
3156 ...rest
3257 } = props ;
3358
3459 const isMobile = useIsMobile ( ) ;
3560 const isModal = modal === 'mobile' ? isMobile : modal ;
3661
62+ // Internal state for uncontrolled mode (only used when open prop is undefined)
3763 const [ open , setOpen ] = React . useState ( openState ?? false ) ;
3864
39- // Use prop if provided ( controlled), otherwise use internal state (uncontrolled )
65+ // Determine actual open state: controlled (from prop) or uncontrolled (from internal state)
4066 const isOpen = openState !== undefined ? openState : open ;
4167
4268 const handleClose = React . useCallback ( ( ) => {
4369 if ( openState !== undefined ) {
44- // Controlled mode: notify parent
45- onClose ?.( ) ;
70+ // Controlled mode: parent manages state, notify via callback with new state
71+ onOpenChange ?.( false ) ;
4672 } else {
47- // Uncontrolled mode: update internal state
73+ // Uncontrolled mode: update internal state and sync body class if needed
4874 setOpen ( false ) ;
4975 if ( toggleClass ) {
5076 document . body . classList . remove ( toggleClass ) ;
5177 }
5278 }
53- } , [ openState , onClose , toggleClass ] ) ;
79+ } , [ openState , onOpenChange , toggleClass ] ) ;
5480
81+ // Sync the sheet state with the body class if the toggleClass is set
5582 React . useEffect ( ( ) => {
5683 if ( ! toggleClass ) {
5784 return ;
@@ -62,17 +89,13 @@ export function SideSheet(
6289 if ( mutation . attributeName === 'class' ) {
6390 const shouldBeOpen = document . body . classList . contains ( toggleClass ) ;
6491 if ( openState !== undefined ) {
65- // Controlled mode: notify parent if state should change
92+ // Controlled mode: sync with parent's state
93+ // Notify parent of state change via onOpenChange
6694 if ( shouldBeOpen !== openState ) {
67- if ( shouldBeOpen ) {
68- // Opening via class - no callback, just sync
69- // Parent should handle this via toggleClass observation
70- } else {
71- onClose ?.( ) ;
72- }
95+ onOpenChange ?.( shouldBeOpen ) ;
7396 }
7497 } else {
75- // Uncontrolled mode: update internal state
98+ // Uncontrolled mode: sync internal state with body class
7699 setOpen ( shouldBeOpen ) ;
77100 }
78101 }
@@ -83,13 +106,17 @@ export function SideSheet(
83106 observer . observe ( document . body , { attributes : true } ) ;
84107
85108 return ( ) => observer . disconnect ( ) ;
86- } , [ toggleClass , openState , onClose ] ) ;
109+ } , [ toggleClass , openState , onOpenChange ] ) ;
87110
88111 return (
89112 < >
90- { isModal && withShim ? (
91- < SideSheetShim className = { isOpen ? '' : 'hidden opacity-0' } onClick = { handleClose } />
113+ { isModal && withScrim ? (
114+ < SideSheetScrim
115+ className = { isOpen ? '' : 'hidden opacity-0 backdrop-blur-none' }
116+ onClick = { handleClose }
117+ />
92118 ) : null }
119+
93120 < aside
94121 className = { tcls (
95122 'side-sheet' ,
@@ -100,7 +127,7 @@ export function SideSheet(
100127 : side === 'left'
101128 ? 'hydrated:animate-exit-to-left'
102129 : 'hydrated:animate-exit-to-right' ,
103- 'fixed inset-y-0 z-50' ,
130+ 'fixed inset-y-0 z-41' , // Above the side sheet scrim on z-40
104131 side === 'left' ? 'left-0' : 'right-0' ,
105132 withCloseButton ? ( side === 'left' ? 'mr-16' : 'ml-16' ) : '' ,
106133 isOpen ? '' : 'hidden' ,
@@ -125,11 +152,12 @@ export function SideSheet(
125152 ) ;
126153}
127154
128- export function SideSheetShim ( props : { className ?: ClassValue ; onClick ?: ( ) => void } ) {
155+ /** Backdrop overlay shown behind the modal sheet */
156+ export function SideSheetScrim ( props : { className ?: ClassValue ; onClick ?: ( ) => void } ) {
129157 const { className, onClick } = props ;
130158 return (
131159 < div
132- id = "side-sheet-shim "
160+ id = "side-sheet-scrim "
133161 onClick = { ( ) => {
134162 onClick ?.( ) ;
135163 } }
@@ -139,13 +167,14 @@ export function SideSheetShim(props: { className?: ClassValue; onClick?: () => v
139167 }
140168 } }
141169 className = { tcls (
142- 'fixed inset-0 z-40 items-start bg-tint-base/3 not-hydrated:opacity-0 starting:opacity-0 backdrop-blur-md transition-[opacity,display,filter] transition-discrete duration-250' ,
170+ 'fixed inset-0 z-40 items-start bg-tint-base/3 not-hydrated:opacity-0 starting:opacity-0 backdrop-blur-md starting:backdrop-blur-none transition-[opacity,display,backdrop- filter] transition-discrete duration-250 dark:bg-tint-base/9 ' ,
143171 className
144172 ) }
145173 />
146174 ) ;
147175}
148176
177+ /** Close button displayed outside the sheet when modal */
149178export function SideSheetCloseButton ( props : { className ?: ClassValue ; onClick ?: ( ) => void } ) {
150179 const { className, onClick } = props ;
151180 const language = useLanguage ( ) ;
0 commit comments