Skip to main content

dwui/components/widgets/
tab_list.rs

1use crate::theme::prelude::*;
2use crate::utils::component_id;
3use dominator::{clone, events, html, Dom, EventOptions};
4use dwind::prelude::*;
5use futures_signals::signal::SignalExt;
6use futures_signals::signal_vec::SignalVecExt;
7use futures_signals_component_macro::component;
8use std::rc::Rc;
9use web_sys::wasm_bindgen::JsCast;
10
11/// An accessible tab list following the WAI-ARIA tabs pattern.
12///
13/// Renders a `role="tablist"` with one `role="tab"` button per `(key, label)`
14/// entry. The selected tab is controlled by the `selected` key signal;
15/// activating a tab (click, Enter, or Space) invokes `on_select` with its key.
16/// Keyboard support: Left/Right arrows move selection and focus to the
17/// previous/next tab (wrapping), Home/End jump to the first/last tab.
18///
19/// Each tab gets the DOM id `{tablist-id}-tab-{key}`, so tab panels can point
20/// back at their tab via `aria-labelledby`.
21#[component(render_fn = tab_list)]
22struct TabList {
23    #[signal_vec]
24    #[default(vec![])]
25    tabs: (String, String),
26
27    #[signal]
28    #[default("".to_string())]
29    selected: String,
30
31    #[default(Box::new(|_|{}))]
32    on_select: dyn Fn(String) + 'static,
33
34    /// Accessible name for the tab list
35    #[signal]
36    #[default("Tabs".to_string())]
37    aria_label: String,
38}
39
40pub fn tab_list(props: TabListProps) -> Dom {
41    let TabListProps {
42        tabs,
43        selected,
44        on_select,
45        aria_label,
46        apply,
47    } = props;
48
49    let on_select = Rc::new(on_select);
50    let selected = selected.broadcast();
51    let tabs = tabs.to_signal_cloned().broadcast();
52
53    let tablist_id = component_id("tablist");
54
55    let tab_dom_id = {
56        let tablist_id = tablist_id.clone();
57        move |key: &str| format!("{}-tab-{}", tablist_id, key)
58    };
59
60    html!("div", {
61        .attr("role", "tablist")
62        .attr("id", &tablist_id)
63        .attr_signal("aria-label", aria_label)
64        .dwclass!("flex flex-row gap-1 border-b")
65        .dwclass!("dwui-border-void-700 is(.light *):dwui-border-void-300")
66        .children_signal_vec(tabs.signal_cloned().map(clone!(selected, tab_dom_id => move |tabs_vec| {
67            tabs_vec
68                .iter()
69                .enumerate()
70                .map(|(index, (key, label))| {
71                    let key = key.clone();
72                    let is_selected = selected
73                        .signal_cloned()
74                        .map(clone!(key => move |s| s == key))
75                        .broadcast();
76
77                    html!("button", {
78                        .attr("type", "button")
79                        .attr("role", "tab")
80                        .attr("id", &tab_dom_id(&key))
81                        .attr_signal("aria-selected", is_selected.signal().map(|v| if v { "true" } else { "false" }))
82                        .attr_signal("tabindex", is_selected.signal().map(|v| if v { "0" } else { "-1" }))
83                        .dwclass!("h-10 p-l-4 p-r-4 cursor-pointer font-medium text-base transition-colors bg-transparent")
84                        .dwclass!("rounded-t-md border-b-2")
85                        .dwclass!("dwui-focusable")
86                        .dwclass_signal!("dwui-text-on-primary-50 is(.light *):dwui-text-on-primary-950", is_selected.signal())
87                        .dwclass_signal!("dwui-border-primary-400 is(.light *):dwui-border-primary-600", is_selected.signal())
88                        .dwclass_signal!(
89                            "dwui-text-on-primary-400 hover:dwui-text-on-primary-200 is(.light *):dwui-text-on-primary-600 is(.light *):hover:dwui-text-on-primary-800",
90                            is_selected.signal().map(|v| !v)
91                        )
92                        .dwclass_signal!("border-transparent", is_selected.signal().map(|v| !v))
93                        .text(label)
94                        .event(clone!(on_select, key => move |_: events::Click| {
95                            (on_select)(key.clone());
96                        }))
97                        .event_with_options(&EventOptions::preventable(), clone!(on_select, tabs_vec, tab_dom_id => move |e: events::KeyDown| {
98                            let count = tabs_vec.len();
99
100                            if count == 0 {
101                                return;
102                            }
103
104                            let target_index = match e.key().as_str() {
105                                "ArrowRight" => (index + 1) % count,
106                                "ArrowLeft" => (index + count - 1) % count,
107                                "Home" => 0,
108                                "End" => count - 1,
109                                _ => return,
110                            };
111
112                            e.prevent_default();
113
114                            let target_key = tabs_vec[target_index].0.clone();
115
116                            // Move focus along with selection (roving tabindex)
117                            if let Some(element) = web_sys::window()
118                                .and_then(|w| w.document())
119                                .and_then(|d| d.get_element_by_id(&tab_dom_id(&target_key)))
120                            {
121                                if let Ok(element) = element.dyn_into::<web_sys::HtmlElement>() {
122                                    let _ = element.focus();
123                                }
124                            }
125
126                            (on_select)(target_key);
127                        }))
128                    })
129                })
130                .collect::<Vec<_>>()
131        })).to_signal_vec())
132        .apply_if(apply.is_some(), |b| b.apply(apply.unwrap()))
133    })
134}