side_sheet.rs
1 use dioxus::prelude::*; 2 use lucide_dioxus::X; 3 4 // Side from which the sheet appears 5 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 6 pub enum SideSheetSide { 7 Left, 8 Right, 9 } 10 11 impl SideSheetSide { 12 fn content_classes(&self) -> &'static str { 13 match self { 14 SideSheetSide::Left => "inset-y-0 left-0 h-full w-3/4 sm:max-w-sm", 15 SideSheetSide::Right => "inset-y-0 right-0 h-full w-3/4 sm:max-w-sm", 16 } 17 } 18 19 fn animation_classes(&self, is_open: bool) -> &'static str { 20 match (self, is_open) { 21 (SideSheetSide::Left, true) => "translate-x-0", 22 (SideSheetSide::Left, false) => "-translate-x-full", 23 (SideSheetSide::Right, true) => "translate-x-0", 24 (SideSheetSide::Right, false) => "translate-x-full", 25 } 26 } 27 } 28 29 // Context for sharing state between side sheet components 30 #[derive(Clone)] 31 pub struct SideSheetContext { 32 is_open: Signal<bool>, 33 side: SideSheetSide, 34 } 35 36 // Main SideSheet component that provides context 37 #[derive(Props, Clone, PartialEq)] 38 pub struct SideSheetProps { 39 #[props(default = SideSheetSide::Right)] 40 pub side: SideSheetSide, 41 42 #[props(default = false)] 43 pub default_open: bool, 44 45 pub children: Element, 46 } 47 48 #[component] 49 pub fn SideSheet(props: SideSheetProps) -> Element { 50 let is_open = use_signal(|| props.default_open); 51 52 let context = SideSheetContext { 53 is_open, 54 side: props.side, 55 }; 56 57 use_context_provider(|| context); 58 59 rsx! { 60 {props.children} 61 } 62 } 63 64 // Trigger component to open the side sheet 65 #[derive(Props, Clone, PartialEq)] 66 pub struct SideSheetTriggerProps { 67 pub children: Element, 68 } 69 70 #[component] 71 pub fn SideSheetTrigger(props: SideSheetTriggerProps) -> Element { 72 let mut context = use_context::<SideSheetContext>(); 73 74 let on_click = move |_| { 75 context.is_open.set(true); 76 }; 77 78 rsx! { 79 div { class: "w-auto inline-block", 80 onclick: on_click, 81 {props.children} 82 } 83 } 84 } 85 86 // Close trigger component 87 #[derive(Props, Clone, PartialEq)] 88 pub struct SideSheetCloseProps { 89 pub children: Element, 90 } 91 92 #[component] 93 pub fn SideSheetClose(props: SideSheetCloseProps) -> Element { 94 let mut context = use_context::<SideSheetContext>(); 95 96 let on_click = move |_| { 97 context.is_open.set(false); 98 }; 99 100 rsx! { 101 div { 102 onclick: on_click, 103 {props.children} 104 } 105 } 106 } 107 108 // Overlay component 109 #[derive(Props, Clone, PartialEq)] 110 pub struct SideSheetOverlayProps { 111 #[props(default = "bg-black/80".to_string())] 112 pub class: String, 113 } 114 115 #[component] 116 pub fn SideSheetOverlay(props: SideSheetOverlayProps) -> Element { 117 let mut context = use_context::<SideSheetContext>(); 118 119 let on_click = move |_| { 120 context.is_open.set(false); 121 }; 122 123 let is_open = *context.is_open.read(); 124 125 rsx! { 126 div { 127 class: "fixed inset-0 z-50 {props.class} data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:hidden", 128 "data-state": if is_open { "open" } else { "closed" }, 129 onclick: on_click, 130 aria_hidden: "true", 131 } 132 } 133 } 134 135 // Content component 136 #[derive(Props, Clone, PartialEq)] 137 pub struct SideSheetContentProps { 138 #[props(default = "".to_string())] 139 pub class: String, 140 141 pub children: Element, 142 } 143 144 #[component] 145 pub fn SideSheetContent(props: SideSheetContentProps) -> Element { 146 let context = use_context::<SideSheetContext>(); 147 let is_open = *context.is_open.read(); 148 149 // We'll handle escape key with the aria-modal attribute 150 // which provides built-in keyboard handling 151 152 let side_classes = context.side.content_classes(); 153 let animation_classes = context.side.animation_classes(is_open); 154 155 rsx! { 156 // Portal-like behavior - render at the root level 157 div { 158 class: "fixed z-50", 159 160 // Overlay 161 SideSheetOverlay {} 162 163 // Content 164 div { 165 class: "fixed z-50 bg-background border-l border-border shadow-lg transition ease-in-out duration-300 {side_classes} {animation_classes} {props.class}", 166 role: "dialog", 167 aria_modal: "true", 168 aria_labelledby: "side-sheet-title", 169 aria_describedby: "side-sheet-description", 170 171 // Focus trap would be implemented here in a production version 172 173 {props.children} 174 } 175 } 176 } 177 } 178 179 // Header component for common pattern 180 #[derive(Props, Clone, PartialEq)] 181 pub struct SideSheetHeaderProps { 182 #[props(default = "".to_string())] 183 pub class: String, 184 185 pub children: Element, 186 } 187 188 #[component] 189 pub fn SideSheetHeader(props: SideSheetHeaderProps) -> Element { 190 rsx! { 191 div { 192 class: "flex flex-col space-y-2 text-center sm:text-left {props.class}", 193 {props.children} 194 } 195 } 196 } 197 198 // Title component for common pattern 199 #[derive(Props, Clone, PartialEq)] 200 pub struct SideSheetTitleProps { 201 #[props(default = "".to_string())] 202 pub class: String, 203 204 pub children: Element, 205 } 206 207 #[component] 208 pub fn SideSheetTitle(props: SideSheetTitleProps) -> Element { 209 rsx! { 210 h2 { 211 id: "side-sheet-title", 212 class: "text-lg font-semibold leading-none tracking-tight {props.class}", 213 {props.children} 214 } 215 } 216 } 217 218 // Description component for common pattern 219 #[derive(Props, Clone, PartialEq)] 220 pub struct SideSheetDescriptionProps { 221 #[props(default = "".to_string())] 222 pub class: String, 223 224 pub children: Element, 225 } 226 227 #[component] 228 pub fn SideSheetDescription(props: SideSheetDescriptionProps) -> Element { 229 rsx! { 230 p { 231 id: "side-sheet-description", 232 class: "text-sm text-muted-foreground {props.class}", 233 {props.children} 234 } 235 } 236 } 237 238 // Body component for main content area 239 #[derive(Props, Clone, PartialEq)] 240 pub struct SideSheetBodyProps { 241 #[props(default = "".to_string())] 242 pub class: String, 243 244 pub children: Element, 245 } 246 247 #[component] 248 pub fn SideSheetBody(props: SideSheetBodyProps) -> Element { 249 rsx! { 250 div { 251 class: "flex-1 overflow-y-auto {props.class}", 252 {props.children} 253 } 254 } 255 } 256 257 // Footer component for action buttons 258 #[derive(Props, Clone, PartialEq)] 259 pub struct SideSheetFooterProps { 260 #[props(default = "".to_string())] 261 pub class: String, 262 263 pub children: Element, 264 } 265 266 #[component] 267 pub fn SideSheetFooter(props: SideSheetFooterProps) -> Element { 268 rsx! { 269 div { 270 class: "flex gap-2 {props.class}", 271 {props.children} 272 } 273 } 274 } 275 276 // Default close button component for convenience 277 #[derive(Props, Clone, PartialEq)] 278 pub struct SideSheetCloseButtonProps { 279 #[props(default = "".to_string())] 280 pub class: String, 281 } 282 283 #[component] 284 pub fn SideSheetCloseButton(props: SideSheetCloseButtonProps) -> Element { 285 let mut context = use_context::<SideSheetContext>(); 286 287 rsx! { 288 button { 289 class: "absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary {props.class}", 290 onclick: move |_| context.is_open.set(false), 291 type: "button", 292 aria_label: "Close", 293 294 X { 295 class: "h-6 w-6" 296 } 297 } 298 } 299 }