use crate::icon::{Icon, IconKind};
use dioxus::prelude::*;
#[derive(Clone, PartialEq)]
pub struct TabListCtx {
pub base_id: String,
}
#[component]
pub fn TabList(
#[props(default)]
id: Option<String>,
#[props(default)]
aria_label: Option<String>,
#[props(default)]
class: String,
children: Element,
) -> Element {
let base_id = crate::a11y::use_unique_id("sd-tabs", id);
use_context_provider(|| TabListCtx { base_id: base_id.clone() });
#[cfg(feature = "theme")]
let theme_val = crate::theme::use_theme()();
#[cfg(feature = "theme")]
let base_override = theme_val.resolve_component_base("tabs").unwrap_or("");
#[cfg(feature = "theme")]
let class_modifiers = theme_val.resolve_class_modifiers("tabs", &class);
#[cfg(not(feature = "theme"))]
let base_override = "";
#[cfg(not(feature = "theme"))]
let class_modifiers = String::new();
rsx! {
div {
role: "tablist",
id: "{base_id}",
"aria-label": aria_label.as_deref(),
"aria-orientation": "horizontal",
class: "sd-tabs-list {base_override} {class_modifiers} {class}",
{children}
}
}
}
#[component]
pub fn Tab(
value: String,
active: Signal<String>,
#[props(default)]
icon: Option<IconKind>,
#[props(default)]
badge: Option<String>,
#[props(default)]
disabled: bool,
#[props(default)]
class: String,
children: Element,
) -> Element {
let ctx = try_use_context::<TabListCtx>();
let base_id = ctx.map(|c| c.base_id).unwrap_or_else(|| "sd-tabs".to_string());
let val_sanitized = value.replace(' ', "-").to_lowercase();
let tab_id = format!("{base_id}-tab-{val_sanitized}");
let panel_id = format!("{base_id}-panel-{val_sanitized}");
let is_active = *active.read() == value;
let target = value.clone();
let active_class = if is_active { "sd-tab--active" } else { "" };
let disabled_class = if disabled { "sd-tab--disabled" } else { "" };
#[cfg(feature = "theme")]
let theme_val = crate::theme::use_theme()();
#[cfg(feature = "theme")]
let base_override = theme_val.resolve_component_base("tab").unwrap_or("");
#[cfg(feature = "theme")]
let class_modifiers = theme_val.resolve_class_modifiers("tab", &class);
#[cfg(not(feature = "theme"))]
let base_override = "";
#[cfg(not(feature = "theme"))]
let class_modifiers = String::new();
let handle_keydown = move |e: KeyboardEvent| {
match e.key() {
Key::ArrowRight => {
e.prevent_default();
let _ = document::eval(r#"
const tab = document.activeElement;
if (tab && tab.getAttribute('role') === 'tab') {
const tablist = tab.closest('[role="tablist"]');
if (tablist) {
const tabs = Array.from(tablist.querySelectorAll('[role="tab"]:not([disabled])'));
const idx = tabs.indexOf(tab);
if (idx >= 0 && tabs.length > 0) {
const next = tabs[(idx + 1) % tabs.length];
if (next) { next.focus(); next.click(); }
}
}
}
"#);
}
Key::ArrowLeft => {
e.prevent_default();
let _ = document::eval(r#"
const tab = document.activeElement;
if (tab && tab.getAttribute('role') === 'tab') {
const tablist = tab.closest('[role="tablist"]');
if (tablist) {
const tabs = Array.from(tablist.querySelectorAll('[role="tab"]:not([disabled])'));
const idx = tabs.indexOf(tab);
if (idx >= 0 && tabs.length > 0) {
const prev = tabs[(idx - 1 + tabs.length) % tabs.length];
if (prev) { prev.focus(); prev.click(); }
}
}
}
"#);
}
Key::Home => {
e.prevent_default();
let _ = document::eval(r#"
const tab = document.activeElement;
if (tab && tab.getAttribute('role') === 'tab') {
const tablist = tab.closest('[role="tablist"]');
if (tablist) {
const tabs = Array.from(tablist.querySelectorAll('[role="tab"]:not([disabled])'));
if (tabs.length > 0 && tabs[0]) { tabs[0].focus(); tabs[0].click(); }
}
}
"#);
}
Key::End => {
e.prevent_default();
let _ = document::eval(r#"
const tab = document.activeElement;
if (tab && tab.getAttribute('role') === 'tab') {
const tablist = tab.closest('[role="tablist"]');
if (tablist) {
const tabs = Array.from(tablist.querySelectorAll('[role="tab"]:not([disabled])'));
if (tabs.length > 0 && tabs[tabs.length - 1]) { tabs[tabs.length - 1].focus(); tabs[tabs.length - 1].click(); }
}
}
"#);
}
_ => {}
}
};
rsx! {
button {
r#type: "button",
role: "tab",
id: "{tab_id}",
tabindex: if is_active && !disabled { "0" } else { "-1" },
disabled: disabled,
"aria-selected": "{is_active}",
"aria-controls": "{panel_id}",
"aria-disabled": if disabled { "true" } else { "false" },
class: "sd-tab {base_override} {active_class} {disabled_class} {class_modifiers} {class}",
onclick: move |_| {
if !disabled {
*active.write() = target.clone();
}
},
onkeydown: handle_keydown,
if let Some(i) = icon {
span {
class: "sd-tab__icon",
"aria-hidden": "true",
Icon { kind: i, size: 14 }
}
}
{children}
if let Some(b) = badge {
span {
class: "sd-tab__badge",
span { class: "sd-visually-hidden", " (" }
"{b}"
span { class: "sd-visually-hidden", ")" }
}
}
}
}
}
#[component]
pub fn TabPanel(
value: String,
active: Signal<String>,
#[props(default)]
keep_mounted: bool,
#[props(default)]
class: String,
children: Element,
) -> Element {
#[cfg(feature = "theme")]
let theme_val = crate::theme::use_theme()();
let is_active = *active.read() == value;
if !is_active && !keep_mounted {
return rsx! {};
}
let ctx = try_use_context::<TabListCtx>();
let base_id = ctx.map(|c| c.base_id).unwrap_or_else(|| "sd-tabs".to_string());
let val_sanitized = value.replace(' ', "-").to_lowercase();
let tab_id = format!("{base_id}-tab-{val_sanitized}");
let panel_id = format!("{base_id}-panel-{val_sanitized}");
#[cfg(feature = "theme")]
let base_override = theme_val.resolve_component_base("tab-panel").unwrap_or("");
#[cfg(feature = "theme")]
let class_modifiers = theme_val.resolve_class_modifiers("tab-panel", &class);
#[cfg(not(feature = "theme"))]
let base_override = "";
#[cfg(not(feature = "theme"))]
let class_modifiers = String::new();
rsx! {
div {
role: "tabpanel",
id: "{panel_id}",
tabindex: "0",
hidden: !is_active,
"aria-labelledby": "{tab_id}",
class: "sd-tab-panel {base_override} {class_modifiers} {class}",
{children}
}
}
}