/ libs / ui / src / components / side_sheet.rs
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  }