1use crate::components::icon::{Icon, IconKind};
2use crate::theme::{ThemeTokens, use_theme};
3use dioxus::prelude::*;
4
5#[derive(Props, Clone, PartialEq)]
7pub struct LayoutProps {
8 #[props(optional)]
9 pub class: Option<String>,
10 #[props(optional)]
11 pub style: Option<String>,
12 #[props(optional)]
14 pub has_sider: Option<bool>,
15 pub children: Element,
16}
17
18#[component]
20pub fn Layout(props: LayoutProps) -> Element {
21 let LayoutProps {
22 class,
23 style,
24 has_sider,
25 children,
26 } = props;
27
28 let mut class_list = vec!["adui-layout".to_string()];
29 if has_sider.unwrap_or(false) {
30 class_list.push("adui-layout-has-sider".into());
31 }
32 if let Some(extra) = class {
33 class_list.push(extra);
34 }
35 let class_attr = class_list.join(" ");
36
37 rsx! {
38 div {
39 class: "{class_attr}",
40 style: style.unwrap_or_default(),
41 {children}
42 }
43 }
44}
45
46#[component]
48pub fn Header(props: LayoutProps) -> Element {
49 let LayoutProps { class, style, .. } = props.clone();
50 let theme = use_theme();
51 let tokens = theme.tokens();
52 let class_attr = format!("adui-layout-header {}", class.unwrap_or_default());
53 let style_attr = format!(
54 "background:{};color:{};{}",
55 tokens.color_bg_container,
56 tokens.color_text,
57 style.unwrap_or_default()
58 );
59 rsx! {
60 header {
61 class: "{class_attr}",
62 style: "{style_attr}",
63 {props.children}
64 }
65 }
66}
67
68#[component]
70pub fn Content(props: LayoutProps) -> Element {
71 let LayoutProps {
72 class,
73 style,
74 children,
75 ..
76 } = props;
77 let class_attr = format!("adui-layout-content {}", class.unwrap_or_default());
78 rsx! {
79 main {
80 class: "{class_attr}",
81 style: style.unwrap_or_default(),
82 {children}
83 }
84 }
85}
86
87#[component]
89pub fn Footer(props: LayoutProps) -> Element {
90 let LayoutProps {
91 class,
92 style,
93 children,
94 ..
95 } = props;
96 let theme = use_theme();
97 let tokens = theme.tokens();
98 let class_attr = format!("adui-layout-footer {}", class.unwrap_or_default());
99 let style_attr = format!(
100 "color:{};{}",
101 tokens.color_text_secondary,
102 style.unwrap_or_default()
103 );
104 rsx! {
105 footer {
106 class: "{class_attr}",
107 style: "{style_attr}",
108 {children}
109 }
110 }
111}
112
113#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
115pub enum SiderTheme {
116 Light,
117 #[default]
118 Dark,
119}
120
121#[derive(Props, Clone, PartialEq)]
123pub struct SiderProps {
124 #[props(optional)]
125 pub width: Option<f32>,
126 #[props(optional)]
127 pub collapsed_width: Option<f32>,
128 #[props(optional)]
129 pub collapsed: Option<bool>,
130 #[props(default)]
131 pub default_collapsed: bool,
132 #[props(default)]
133 pub collapsible: bool,
134 #[props(default)]
135 pub reverse_arrow: bool,
136 #[props(optional)]
137 pub trigger: Option<Element>,
138 #[props(optional)]
139 pub zero_width_trigger_style: Option<String>,
140 #[props(default = SiderTheme::Dark)]
141 pub theme: SiderTheme,
142 #[props(default = true)]
143 pub has_border: bool,
144 #[props(optional)]
145 pub on_collapse: Option<EventHandler<bool>>,
146 #[props(optional)]
147 pub class: Option<String>,
148 #[props(optional)]
149 pub style: Option<String>,
150 pub children: Element,
151}
152
153#[component]
155pub fn Sider(props: SiderProps) -> Element {
156 let SiderProps {
157 width,
158 collapsed_width,
159 collapsed,
160 default_collapsed,
161 collapsible,
162 reverse_arrow,
163 trigger,
164 zero_width_trigger_style,
165 theme,
166 has_border,
167 on_collapse,
168 class,
169 style,
170 children,
171 } = props;
172
173 let width_value = width.unwrap_or(200.0).max(0.0);
174 let collapsed_value = collapsed_width.unwrap_or(80.0).max(0.0);
175
176 let mut collapsed_state = use_signal(|| collapsed.unwrap_or(default_collapsed));
177 if let Some(external) = collapsed {
178 collapsed_state.set(external);
179 }
180
181 let theme_handle = use_theme();
182 let tokens = theme_handle.tokens();
183 let (bg_color, text_color) = sider_palette(&tokens, theme);
184 let border_color = if has_border {
185 format!("1px solid {}", tokens.color_border)
186 } else {
187 "none".into()
188 };
189
190 let is_collapsed = *collapsed_state.read();
191 let current_width = if is_collapsed {
192 collapsed_value
193 } else {
194 width_value
195 };
196 let width_str = format!("{}px", current_width);
197
198 let mut class_list = vec!["adui-layout-sider".to_string()];
199 class_list.push(match theme {
200 SiderTheme::Light => "adui-layout-sider-light".into(),
201 SiderTheme::Dark => "adui-layout-sider-dark".into(),
202 });
203 if is_collapsed {
204 class_list.push("adui-layout-sider-collapsed".into());
205 }
206 if collapsible {
207 class_list.push("adui-layout-sider-collapsible".into());
208 }
209 if collapsed_value == 0.0 {
210 class_list.push("adui-layout-sider-zero-width".into());
211 }
212 if let Some(extra) = class.as_ref() {
213 class_list.push(extra.clone());
214 }
215 let class_attr = class_list.join(" ");
216
217 let trigger_content = trigger
218 .clone()
219 .unwrap_or_else(|| default_trigger_icon(is_collapsed, reverse_arrow));
220
221 let mut toggle = {
222 let mut collapsed_signal = collapsed_state;
223 let collapsible_flag = collapsible;
224 let handler = on_collapse;
225 move || {
226 if !collapsible_flag {
227 return;
228 }
229 let next = !*collapsed_signal.read();
230 collapsed_signal.set(next);
231 if let Some(cb) = handler.as_ref() {
232 cb.call(next);
233 }
234 }
235 };
236
237 let zero_width_trigger = if collapsible && collapsed_value == 0.0 {
238 let trigger_style = zero_width_trigger_style.unwrap_or_default();
239 let trigger_icon = trigger_content.clone();
240 Some(rsx! {
241 span {
242 class: format_args!(
243 "{} {}",
244 "adui-layout-sider-zero-trigger",
245 if reverse_arrow { "adui-layout-sider-zero-trigger-right" } else { "adui-layout-sider-zero-trigger-left" }
246 ),
247 style: trigger_style,
248 onclick: move |_| toggle(),
249 {trigger_icon}
250 }
251 })
252 } else {
253 None
254 };
255
256 let inline_trigger = if collapsible && collapsed_value > 0.0 {
257 let trigger_icon = trigger_content;
258 Some(rsx! {
259 div {
260 class: "adui-layout-sider-trigger",
261 style: format!("width:{width_str};"),
262 onclick: move |_| toggle(),
263 {trigger_icon}
264 }
265 })
266 } else {
267 None
268 };
269
270 let mut style_buffer = format!(
271 "flex:0 0 {w};max-width:{w};min-width:{w};width:{w};background:{};color:{};border-right:{};",
272 bg_color,
273 text_color,
274 border_color,
275 w = width_str
276 );
277 if let Some(extra) = style.as_ref() {
278 style_buffer.push_str(extra);
279 }
280
281 rsx! {
282 aside {
283 class: "{class_attr}",
284 style: "{style_buffer}",
285 role: "complementary",
286 "aria-expanded": (!is_collapsed).to_string(),
287 div {
288 class: "adui-layout-sider-children",
289 {children}
290 }
291 if let Some(trigger) = zero_width_trigger {
292 {trigger}
293 } else if let Some(trigger) = inline_trigger {
294 {trigger}
295 }
296 }
297 }
298}
299
300fn default_trigger_icon(collapsed: bool, reverse_arrow: bool) -> Element {
301 let should_point_right = if reverse_arrow { !collapsed } else { collapsed };
302 let icon_kind = if should_point_right {
303 IconKind::ArrowRight
304 } else {
305 IconKind::ArrowLeft
306 };
307 rsx!(Icon {
308 kind: icon_kind,
309 size: 16.0
310 })
311}
312
313fn sider_palette(tokens: &ThemeTokens, theme: SiderTheme) -> (String, String) {
314 match theme {
315 SiderTheme::Light => (tokens.color_bg_container.clone(), tokens.color_text.clone()),
316 SiderTheme::Dark => (tokens.color_bg_layout.clone(), "#fafafa".into()),
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn sider_theme_default() {
326 assert_eq!(SiderTheme::default(), SiderTheme::Dark);
327 }
328
329 #[test]
330 fn sider_theme_variants() {
331 assert_ne!(SiderTheme::Light, SiderTheme::Dark);
332 }
333
334 #[test]
335 fn sider_theme_equality() {
336 assert_eq!(SiderTheme::Light, SiderTheme::Light);
337 assert_eq!(SiderTheme::Dark, SiderTheme::Dark);
338 }
339
340 #[test]
341 fn layout_props_defaults() {
342 let props = LayoutProps {
343 class: None,
344 style: None,
345 has_sider: None,
346 children: rsx!(div {}),
347 };
348 assert!(props.class.is_none());
349 assert!(props.style.is_none());
350 assert!(props.has_sider.is_none());
351 }
352
353 #[test]
354 fn layout_props_has_sider() {
355 let props_with_sider = LayoutProps {
356 class: None,
357 style: None,
358 has_sider: Some(true),
359 children: rsx!(div {}),
360 };
361 assert_eq!(props_with_sider.has_sider, Some(true));
362
363 let props_without_sider = LayoutProps {
364 class: None,
365 style: None,
366 has_sider: Some(false),
367 children: rsx!(div {}),
368 };
369 assert_eq!(props_without_sider.has_sider, Some(false));
370 }
371
372 #[test]
373 fn sider_props_defaults() {
374 let props = SiderProps {
375 width: None,
376 collapsed_width: None,
377 collapsed: None,
378 default_collapsed: false,
379 collapsible: false,
380 reverse_arrow: false,
381 trigger: None,
382 zero_width_trigger_style: None,
383 theme: SiderTheme::default(),
384 has_border: true,
385 on_collapse: None,
386 class: None,
387 style: None,
388 children: rsx!(div {}),
389 };
390 assert_eq!(props.default_collapsed, false);
391 assert_eq!(props.collapsible, false);
392 assert_eq!(props.reverse_arrow, false);
393 assert_eq!(props.theme, SiderTheme::Dark);
394 assert_eq!(props.has_border, true);
395 }
396
397 #[test]
398 fn sider_theme_clone() {
399 let original = SiderTheme::Light;
400 let cloned = original;
401 assert_eq!(original, cloned);
402 }
403}