1use crate::components::config_provider::ComponentSize;
10use crate::components::icon::{Icon, IconKind};
11use crate::foundation::{ClassListExt, StyleStringExt, TabsClassNames, TabsSemantic, TabsStyles};
12use dioxus::prelude::*;
13
14#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
16pub enum TabsType {
17 #[default]
18 Line,
19 Card,
20 EditableCard,
21}
22
23impl TabsType {
24 fn as_class(&self) -> &'static str {
25 match self {
26 TabsType::Line => "adui-tabs-line",
27 TabsType::Card => "adui-tabs-card",
28 TabsType::EditableCard => "adui-tabs-editable-card",
29 }
30 }
31}
32
33#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
35pub enum TabPlacement {
36 #[default]
37 Top,
38 Right,
39 Bottom,
40 Left,
41}
42
43impl TabPlacement {
44 fn as_class(&self) -> &'static str {
45 match self {
46 TabPlacement::Top => "adui-tabs-top",
47 TabPlacement::Right => "adui-tabs-right",
48 TabPlacement::Bottom => "adui-tabs-bottom",
49 TabPlacement::Left => "adui-tabs-left",
50 }
51 }
52}
53
54#[derive(Clone, Debug, PartialEq, Eq)]
56pub enum TabEditAction {
57 Add,
58 Remove(String),
59}
60
61#[derive(Clone, PartialEq)]
63pub struct TabItem {
64 pub key: String,
65 pub label: String,
66 pub disabled: bool,
67 pub closable: bool,
69 pub icon: Option<Element>,
71 pub content: Option<Element>,
73}
74
75impl TabItem {
76 pub fn new(key: impl Into<String>, label: impl Into<String>, content: Option<Element>) -> Self {
78 Self {
79 key: key.into(),
80 label: label.into(),
81 disabled: false,
82 closable: true,
83 icon: None,
84 content,
85 }
86 }
87
88 pub fn disabled(key: impl Into<String>, label: impl Into<String>) -> Self {
90 Self {
91 key: key.into(),
92 label: label.into(),
93 disabled: true,
94 closable: false,
95 icon: None,
96 content: None,
97 }
98 }
99
100 pub fn closable(mut self, closable: bool) -> Self {
102 self.closable = closable;
103 self
104 }
105
106 pub fn icon(mut self, icon: Element) -> Self {
108 self.icon = Some(icon);
109 self
110 }
111}
112
113fn resolve_initial_active_key(default_key: Option<String>, items: &[TabItem]) -> String {
115 default_key
116 .or_else(|| items.first().map(|t| t.key.clone()))
117 .unwrap_or_default()
118}
119
120#[derive(Props, Clone, PartialEq)]
122pub struct TabsProps {
123 pub items: Vec<TabItem>,
125 #[props(optional)]
127 pub active_key: Option<String>,
128 #[props(optional)]
130 pub default_active_key: Option<String>,
131 #[props(optional)]
133 pub on_change: Option<EventHandler<String>>,
134 #[props(default)]
136 pub r#type: TabsType,
137 #[props(default)]
139 pub tab_placement: TabPlacement,
140 #[props(default)]
142 pub centered: bool,
143 #[props(default)]
145 pub hide_add: bool,
146 #[props(optional)]
148 pub on_edit: Option<EventHandler<TabEditAction>>,
149 #[props(optional)]
151 pub add_icon: Option<Element>,
152 #[props(optional)]
154 pub remove_icon: Option<Element>,
155 #[props(optional)]
157 pub more_icon: Option<Element>,
158 #[props(optional)]
160 pub size: Option<ComponentSize>,
161 #[props(default)]
163 pub destroy_inactive_tab_pane: bool,
164 #[props(optional)]
165 pub class: Option<String>,
166 #[props(optional)]
167 pub style: Option<String>,
168 #[props(optional)]
170 pub class_names: Option<TabsClassNames>,
171 #[props(optional)]
173 pub styles: Option<TabsStyles>,
174}
175
176#[component]
178pub fn Tabs(props: TabsProps) -> Element {
179 let TabsProps {
180 items,
181 active_key,
182 default_active_key,
183 on_change,
184 r#type,
185 tab_placement,
186 centered,
187 hide_add,
188 on_edit,
189 add_icon,
190 remove_icon,
191 more_icon: _more_icon,
192 size,
193 destroy_inactive_tab_pane,
194 class,
195 style,
196 class_names,
197 styles,
198 } = props;
199
200 let initial_key = resolve_initial_active_key(default_active_key.clone(), &items);
202
203 let active_internal: Signal<String> = use_signal(|| initial_key);
204
205 let is_controlled = active_key.is_some();
206 let current_key = active_key
207 .clone()
208 .unwrap_or_else(|| active_internal.read().clone());
209
210 let mut class_list = vec!["adui-tabs".to_string()];
212 class_list.push(r#type.as_class().to_string());
213 class_list.push(tab_placement.as_class().to_string());
214 if centered {
215 class_list.push("adui-tabs-centered".into());
216 }
217 if let Some(sz) = size {
218 match sz {
219 ComponentSize::Small => class_list.push("adui-tabs-sm".into()),
220 ComponentSize::Middle => {}
221 ComponentSize::Large => class_list.push("adui-tabs-lg".into()),
222 }
223 }
224 class_list.push_semantic(&class_names, TabsSemantic::Root);
225 if let Some(extra) = class {
226 class_list.push(extra);
227 }
228 let class_attr = class_list
229 .into_iter()
230 .filter(|s| !s.is_empty())
231 .collect::<Vec<_>>()
232 .join(" ");
233
234 let mut style_attr = style.unwrap_or_default();
235 style_attr.append_semantic(&styles, TabsSemantic::Root);
236
237 let on_change_cb = on_change;
239 let on_edit_cb = on_edit;
240 let is_editable = matches!(r#type, TabsType::EditableCard);
241
242 let add_icon_element = add_icon.unwrap_or_else(|| {
244 rsx! { Icon { kind: IconKind::Plus, size: 14.0 } }
245 });
246
247 let close_icon_element = remove_icon.unwrap_or_else(|| {
248 rsx! { Icon { kind: IconKind::Close, size: 12.0 } }
249 });
250
251 rsx! {
252 div { class: "{class_attr}", style: "{style_attr}",
253 div { class: "adui-tabs-nav",
254 div { class: "adui-tabs-nav-wrap",
255 div { class: "adui-tabs-nav-list",
256 {items.iter().map(|item| {
257 let key = item.key.clone();
258 let key_for_change = key.clone();
259 let key_for_close = key.clone();
260 let label = item.label.clone();
261 let disabled = item.disabled;
262 let closable = item.closable;
263 let icon = item.icon.clone();
264 let is_active = key == current_key;
265 let active_internal_for_tab = active_internal;
266 let on_change_for_tab = on_change_cb;
267 let on_edit_for_close = on_edit_cb;
268
269 rsx! {
270 div {
271 class: {
272 let mut classes = vec!["adui-tabs-tab".to_string()];
273 if is_active {
274 classes.push("adui-tabs-tab-active".into());
275 }
276 if disabled {
277 classes.push("adui-tabs-tab-disabled".into());
278 }
279 classes.join(" ")
280 },
281 role: "tab",
282 aria_selected: "{is_active}",
283 button {
284 r#type: "button",
285 class: "adui-tabs-tab-btn",
286 disabled: disabled,
287 onclick: move |_| {
288 if disabled {
289 return;
290 }
291 if !is_controlled {
292 let mut signal = active_internal_for_tab;
293 signal.set(key_for_change.clone());
294 }
295 if let Some(cb) = on_change_for_tab {
296 cb.call(key_for_change.clone());
297 }
298 },
299 if let Some(icon_el) = icon {
300 span { class: "adui-tabs-tab-icon", {icon_el} }
301 }
302 "{label}"
303 }
304 if is_editable && closable {
305 button {
306 r#type: "button",
307 class: "adui-tabs-tab-remove",
308 onclick: move |evt| {
309 evt.stop_propagation();
310 if let Some(cb) = on_edit_for_close {
311 cb.call(TabEditAction::Remove(key_for_close.clone()));
312 }
313 },
314 {close_icon_element.clone()}
315 }
316 }
317 }
318 }
319 })}
320 }
321 }
322 if is_editable && !hide_add {
323 button {
324 r#type: "button",
325 class: "adui-tabs-nav-add",
326 onclick: move |_| {
327 if let Some(cb) = on_edit_cb {
328 cb.call(TabEditAction::Add);
329 }
330 },
331 {add_icon_element}
332 }
333 }
334 }
335
336 div { class: "adui-tabs-content-holder",
337 div { class: "adui-tabs-content",
338 {items.iter().map(|item| {
339 let key = item.key.clone();
340 let content = item.content.clone();
341 let is_active = key == current_key;
342
343 if destroy_inactive_tab_pane && !is_active {
345 return rsx! {};
346 }
347
348 let pane_class = if is_active {
349 "adui-tabs-tabpane adui-tabs-tabpane-active"
350 } else {
351 "adui-tabs-tabpane adui-tabs-tabpane-hidden"
352 };
353
354 rsx! {
355 div {
356 key: "{key}",
357 class: "{pane_class}",
358 role: "tabpanel",
359 hidden: !is_active,
360 if let Some(node) = content { {node} }
361 }
362 }
363 })}
364 }
365 }
366 }
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 #[test]
375 fn tab_item_new_and_disabled_work() {
376 let t = TabItem::new("k1", "Label", None);
377 assert_eq!(t.key, "k1");
378 assert_eq!(t.label, "Label");
379 assert!(!t.disabled);
380
381 let t2 = TabItem::disabled("k2", "Other");
382 assert_eq!(t2.key, "k2");
383 assert_eq!(t2.label, "Other");
384 assert!(t2.disabled);
385 assert!(t2.content.is_none());
386 }
387
388 #[test]
389 fn resolve_initial_active_key_prefers_default_then_first_item() {
390 let items = vec![TabItem::new("a", "A", None), TabItem::new("b", "B", None)];
391
392 assert_eq!(resolve_initial_active_key(Some("x".into()), &items), "x");
393 assert_eq!(resolve_initial_active_key(None, &items), "a");
394 assert_eq!(resolve_initial_active_key(None, &[]), "");
395 }
396
397 #[test]
398 fn tabs_type_classes() {
399 assert_eq!(TabsType::Line.as_class(), "adui-tabs-line");
400 assert_eq!(TabsType::Card.as_class(), "adui-tabs-card");
401 assert_eq!(TabsType::EditableCard.as_class(), "adui-tabs-editable-card");
402 }
403
404 #[test]
405 fn tab_placement_classes() {
406 assert_eq!(TabPlacement::Top.as_class(), "adui-tabs-top");
407 assert_eq!(TabPlacement::Left.as_class(), "adui-tabs-left");
408 }
409}