AkurAI Build
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");
    }
}