Menu
Public repository / master
AkurAI-Framework / crates/ui/src/kit/tabs.rs
Latest change 3aae9f4e85241bcb775c31f5c03094becd3da56a - Pure-Rust UI layer: akurai-ui, akurai-crypto, Tailwind-grade akurai-css by Ólafur Búi Ólafsson
use super::uid;
use crate::dom::{El, NodeRef};
use crate::html::{button, div};
use crate::platform::focus;
use crate::reactive::Signal;
/// A Radix-style tab list bound to `active` (the selected tab's value).
/// Arrow keys move between tabs, Home/End jump to the ends; selection
/// follows focus. Render the panel yourself from `active`.
pub fn tabs(active: Signal<String>, items: Vec<(&'static str, &'static str)>) -> El {
let refs: Vec<NodeRef> = items.iter().map(|_| NodeRef::new()).collect();
let values: Vec<&'static str> = items.iter().map(|(v, _)| *v).collect();
let group = uid("tabs");
div()
.role("tablist")
.attr("aria-orientation", "horizontal")
.class("inline-flex h-9 items-center gap-1 rounded-md bg-panel p-1 text-muted")
.children(items.into_iter().enumerate().map(|(i, (value, text))| {
let refs = refs.clone();
let values = values.clone();
button()
.typ("button")
.role("tab")
.id(format!("{group}-{value}"))
.node_ref(&refs[i])
.attr("aria-selected", move || (active.get() == value).to_string())
.attr("data-state", move || if active.get() == value { "active" } else { "inactive" })
.attr("tabindex", move || if active.get() == value { "0" } else { "-1" })
.class("inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-sm px-3 py-1 text-sm font-medium transition-all hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent data-[state=active]:bg-bg data-[state=active]:text-fg data-[state=active]:shadow-sm")
.on_click(move |_| active.set(value.to_string()))
.on("keydown", move |e| {
let n = values.len();
let next = match e.key().as_str() {
"ArrowRight" => (i + 1) % n,
"ArrowLeft" => (i + n - 1) % n,
"Home" => 0,
"End" => n - 1,
_ => return,
};
e.prevent_default();
active.set(values[next].to_string());
if let Some(node) = refs[next].get() {
focus(node);
}
})
.child(text)
}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::reactive::signal;
use crate::testing;
#[test]
fn arrow_keys_rove_selection() {
let active = testing::render_with(|| {
let active = signal(String::from("week"));
(
tabs(
active,
vec![("day", "Day"), ("week", "Week"), ("month", "Month")],
),
active,
)
});
let week = testing::get("[role=tab]:text(Week)");
assert_eq!(
testing::attr(week, "aria-selected").as_deref(),
Some("true")
);
assert_eq!(testing::attr(week, "tabindex").as_deref(), Some("0"));
testing::key(week, "ArrowRight");
assert_eq!(active.get_untracked(), "month");
assert_eq!(testing::focused(), testing::find("[role=tab]:text(Month)"));
testing::key(testing::get("[role=tab]:text(Month)"), "ArrowRight");
assert_eq!(active.get_untracked(), "day", "wraps around");
}
}