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