dioxus_ui_system/organisms/
tabs.rs1use dioxus::prelude::*;
6use crate::theme::{use_theme, use_style};
7use crate::styles::Style;
8
9#[derive(Clone, PartialEq)]
11pub struct TabItem {
12 pub id: String,
14 pub label: String,
16 pub icon: Option<String>,
18 pub disabled: bool,
20}
21
22impl TabItem {
23 pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
25 Self {
26 id: id.into(),
27 label: label.into(),
28 icon: None,
29 disabled: false,
30 }
31 }
32
33 pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
35 self.icon = Some(icon.into());
36 self
37 }
38
39 pub fn disabled(mut self, disabled: bool) -> Self {
41 self.disabled = disabled;
42 self
43 }
44}
45
46#[derive(Props, Clone, PartialEq)]
48pub struct TabsProps {
49 pub tabs: Vec<TabItem>,
51 pub active_tab: String,
53 pub on_change: EventHandler<String>,
55 pub children: Element,
57 #[props(default)]
59 pub variant: TabsVariant,
60 #[props(default)]
62 pub style: Option<String>,
63}
64
65#[derive(Default, Clone, PartialEq)]
67pub enum TabsVariant {
68 #[default]
70 Default,
71 Enclosed,
73 Soft,
75}
76
77#[component]
79pub fn Tabs(props: TabsProps) -> Element {
80 let _theme = use_theme();
81 let variant = props.variant.clone();
82
83 let tabs_container_style = use_style(move |t| {
84 match variant {
85 TabsVariant::Default => Style::new()
86 .w_full()
87 .border_bottom(1, &t.colors.border)
88 .flex()
89 .gap(&t.spacing, "md")
90 .build(),
91 TabsVariant::Enclosed => Style::new()
92 .w_full()
93 .bg(&t.colors.muted)
94 .rounded(&t.radius, "md")
95 .p(&t.spacing, "xs")
96 .flex()
97 .gap(&t.spacing, "xs")
98 .build(),
99 TabsVariant::Soft => Style::new()
100 .w_full()
101 .flex()
102 .gap(&t.spacing, "sm")
103 .build(),
104 }
105 });
106
107 rsx! {
108 div {
109 style: "{tabs_container_style} {props.style.clone().unwrap_or_default()}",
110
111 for tab in props.tabs.clone() {
112 TabButton {
113 tab: tab.clone(),
114 is_active: props.active_tab == tab.id,
115 variant: props.variant.clone(),
116 on_click: props.on_change.clone(),
117 }
118 }
119 }
120 }
121}
122
123#[derive(Props, Clone, PartialEq)]
124struct TabButtonProps {
125 tab: TabItem,
126 is_active: bool,
127 variant: TabsVariant,
128 on_click: EventHandler<String>,
129}
130
131#[component]
132fn TabButton(props: TabButtonProps) -> Element {
133 let _theme = use_theme();
134 let mut is_hovered = use_signal(|| false);
135
136 let is_active = props.is_active;
137 let is_disabled = props.tab.disabled;
138 let variant = props.variant.clone();
139
140 let button_style = use_style(move |t| {
141 let base = Style::new()
142 .inline_flex()
143 .items_center()
144 .gap(&t.spacing, "xs")
145 .px(&t.spacing, "md")
146 .py(&t.spacing, "sm")
147 .text(&t.typography, "sm")
148 .font_weight(500)
149 .cursor(if is_disabled { "not-allowed" } else { "pointer" })
150 .transition("all 150ms ease")
151 .border(0, &t.colors.border)
152 .bg_transparent()
153 .outline("none");
154
155 match variant {
156 TabsVariant::Default => {
157 let styled = if is_active {
158 base.text_color(&t.colors.foreground)
159 .border_bottom(2, &t.colors.primary)
160 .mb_px(-1)
161 } else {
162 base.text_color(&t.colors.muted_foreground)
163 };
164
165 if is_hovered() && !is_disabled && !is_active {
166 styled.text_color(&t.colors.foreground)
167 } else {
168 styled
169 }.build()
170 }
171 TabsVariant::Enclosed => {
172 if is_active {
173 base.bg(&t.colors.background)
174 .text_color(&t.colors.foreground)
175 .shadow(&t.shadows.sm)
176 .rounded(&t.radius, "sm")
177 .build()
178 } else if is_hovered() && !is_disabled {
179 base.text_color(&t.colors.foreground).build()
180 } else {
181 base.text_color(&t.colors.muted_foreground).build()
182 }
183 }
184 TabsVariant::Soft => {
185 if is_active {
186 base.bg(&t.colors.secondary)
187 .text_color(&t.colors.secondary_foreground)
188 .rounded(&t.radius, "md")
189 .build()
190 } else if is_hovered() && !is_disabled {
191 base.bg(&t.colors.muted)
192 .text_color(&t.colors.foreground)
193 .rounded(&t.radius, "md")
194 .build()
195 } else {
196 base.text_color(&t.colors.muted_foreground).build()
197 }
198 }
199 }
200 });
201
202 let handle_click = move |_| {
203 if !is_disabled {
204 props.on_click.call(props.tab.id.clone());
205 }
206 };
207
208 let has_icon = props.tab.icon.is_some();
209
210 rsx! {
211 button {
212 style: "{button_style}",
213 disabled: is_disabled,
214 onclick: handle_click,
215 onmouseenter: move |_| if !is_disabled { is_hovered.set(true) },
216 onmouseleave: move |_| is_hovered.set(false),
217
218 if has_icon {
219 TabIcon { name: props.tab.icon.clone().unwrap() }
220 }
221
222 "{props.tab.label}"
223 }
224 }
225}
226
227#[derive(Props, Clone, PartialEq)]
228struct TabIconProps {
229 name: String,
230}
231
232#[component]
233fn TabIcon(props: TabIconProps) -> Element {
234 rsx! {
235 svg {
236 view_box: "0 0 24 24",
237 fill: "none",
238 stroke: "currentColor",
239 stroke_width: "2",
240 stroke_linecap: "round",
241 stroke_linejoin: "round",
242 style: "width: 16px; height: 16px;",
243
244 match props.name.as_str() {
245 "settings" => rsx! {
246 path { d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" }
247 circle { cx: "12", cy: "12", r: "3" }
248 },
249 _ => rsx! {
250 circle { cx: "12", cy: "12", r: "10" }
251 },
252 }
253 }
254 }
255}
256
257#[derive(Props, Clone, PartialEq)]
259pub struct TabPanelProps {
260 pub children: Element,
262 #[props(default)]
264 pub style: Option<String>,
265}
266
267#[component]
269pub fn TabPanel(props: TabPanelProps) -> Element {
270 let _theme = use_theme();
271
272 let panel_style = use_style(|t| {
273 Style::new()
274 .w_full()
275 .pt(&t.spacing, "md")
276 .build()
277 });
278
279 rsx! {
280 div {
281 role: "tabpanel",
282 style: "{panel_style} {props.style.clone().unwrap_or_default()}",
283 {props.children}
284 }
285 }
286}
287
288#[derive(Props, Clone, PartialEq)]
290pub struct VerticalTabsProps {
291 pub tabs: Vec<TabItem>,
293 pub active_tab: String,
295 pub on_change: EventHandler<String>,
297 #[props(default)]
299 pub style: Option<String>,
300}
301
302#[component]
304pub fn VerticalTabs(props: VerticalTabsProps) -> Element {
305 let _theme = use_theme();
306
307 let container_style = use_style(|t| {
308 Style::new()
309 .flex()
310 .gap(&t.spacing, "lg")
311 .build()
312 });
313
314 let sidebar_style = use_style(|t| {
315 Style::new()
316 .w_px(200)
317 .flex()
318 .flex_col()
319 .gap(&t.spacing, "xs")
320 .build()
321 });
322
323 rsx! {
324 div {
325 style: "{container_style} {props.style.clone().unwrap_or_default()}",
326
327 div {
328 style: "{sidebar_style}",
329
330 for tab in props.tabs.clone() {
331 VerticalTabButton {
332 tab: tab.clone(),
333 is_active: props.active_tab == tab.id,
334 on_click: props.on_change.clone(),
335 }
336 }
337 }
338 }
339 }
340}
341
342#[derive(Props, Clone, PartialEq)]
343struct VerticalTabButtonProps {
344 tab: TabItem,
345 is_active: bool,
346 on_click: EventHandler<String>,
347}
348
349#[component]
350fn VerticalTabButton(props: VerticalTabButtonProps) -> Element {
351 let _theme = use_theme();
352 let mut is_hovered = use_signal(|| false);
353
354 let is_active = props.is_active;
355 let is_disabled = props.tab.disabled;
356
357 let button_style = use_style(move |t| {
358 let base = Style::new()
359 .w_full()
360 .flex()
361 .items_center()
362 .gap(&t.spacing, "sm")
363 .px(&t.spacing, "md")
364 .py(&t.spacing, "sm")
365 .rounded(&t.radius, "md")
366 .text(&t.typography, "sm")
367 .font_weight(500)
368 .cursor(if is_disabled { "not-allowed" } else { "pointer" })
369 .transition("all 150ms ease")
370 .border(0, &t.colors.border)
371 .bg_transparent()
372 .text_align_left();
373
374 if is_active {
375 base.bg(&t.colors.secondary)
376 .text_color(&t.colors.secondary_foreground)
377 } else if is_hovered() && !is_disabled {
378 base.bg(&t.colors.muted)
379 .text_color(&t.colors.foreground)
380 } else {
381 base.text_color(&t.colors.muted_foreground)
382 }.build()
383 });
384
385 let handle_click = move |_| {
386 if !is_disabled {
387 props.on_click.call(props.tab.id.clone());
388 }
389 };
390
391 rsx! {
392 button {
393 style: "{button_style}",
394 disabled: is_disabled,
395 onclick: handle_click,
396 onmouseenter: move |_| if !is_disabled { is_hovered.set(true) },
397 onmouseleave: move |_| is_hovered.set(false),
398 "{props.tab.label}"
399 }
400 }
401}