1use crate::{
3 behavior::roving::{self, use_member, Roster},
4 core::{
5 assets,
6 attrs::{availability_attributes, component_attributes},
7 },
8 prelude::*,
9 theme::use_stylesheets,
10};
11const BEM: Bem = Bem::new("fc-tabs");
12const LIST: Bem = Bem::new("fc-tabs__list");
13fc_modifier! { enum ListStyle for LIST { Underline => None, Segmented => "segmented" } }
14
15#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum Activation {
18 #[default]
20 Automatic,
21 Manual,
23}
24
25#[derive(Clone, Copy)]
26struct TabsContext {
27 roster: Roster,
28 selected: Controllable<Option<String>>,
29 focus: Signal<Option<u64>>,
30 base: Signal<Id>,
31 orientation: Orientation,
32 activation: Activation,
33}
34impl TabsContext {
35 fn current(&self) -> Option<String> {
37 self.selected.get().or_else(|| {
38 self.roster
39 .read()
40 .into_iter()
41 .find(|m| m.enabled)
42 .map(|m| m.value)
43 })
44 }
45}
46
47#[component]
53pub fn Tabs(
54 label: AccessibleName,
55 #[props(default)] orientation: Orientation,
56 #[props(default)] segmented: bool,
57 #[props(default)] activation: Activation,
58 #[props(default)] value: Option<ReadSignal<Option<String>>>,
59 #[props(default)] default_value: Option<String>,
60 #[props(default)] onchange: Option<EventHandler<String>>,
61 tabs: Element,
63 children: Element,
65 #[props(default)] class: Option<String>,
66 #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
67) -> Element {
68 let selected = use_controllable(
69 value,
70 || default_value,
71 onchange.map(|onchange| {
72 EventHandler::new(move |value: Option<String>| {
73 if let Some(value) = value {
74 onchange.call(value);
75 }
76 })
77 }),
78 );
79 let base = use_id();
80 let roster = use_context_provider(Roster::new);
81 use_context_provider(|| TabsContext {
82 roster,
83 selected,
84 focus: Signal::new(None),
85 base: Signal::new(base),
86 orientation,
87 activation,
88 });
89 let attributes = component_attributes(
90 attributes,
91 ClassList::new(BEM.block()).extend(class.as_deref()),
92 &[],
93 );
94 let list_class = ClassList::new(LIST.block())
95 .with_modifier(
96 LIST,
97 if segmented {
98 ListStyle::Segmented
99 } else {
100 ListStyle::Underline
101 },
102 )
103 .to_string();
104 rsx! {
105 if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_TABS_CSS } }
106 div { ..attributes,
107 div { class: list_class, role: "tablist", aria_label: label.to_string(),
108 aria_orientation: (orientation == Orientation::Vertical).then_some("vertical"),
109 {tabs}
110 }
111 {children}
112 }
113 }
114}
115
116fn tab_id(base: &Id, value: &str) -> String {
117 use std::fmt::Write as _;
118 let mut safe = String::with_capacity(value.len());
122 for c in value.chars() {
123 if c.is_alphanumeric() || c == '-' {
124 safe.push(c);
125 } else {
126 let _ = write!(safe, "_{:x}_", u32::from(c));
127 }
128 }
129 format!("{base}-tab-{safe}")
130}
131
132#[component]
134pub fn Tab(
135 value: String,
136 #[props(default)] count: Option<u32>,
137 #[props(default)] availability: Availability,
138 children: Element,
139) -> Element {
140 let enabled = availability == Availability::Enabled;
141 let joined = use_member(&value, availability != Availability::Disabled, enabled);
142 let direction = use_direction();
143 let Some(mut context) = try_consume_context::<TabsContext>() else {
144 return rsx! {};
145 };
146 let Some((roster, key)) = joined else {
147 return rsx! {};
148 };
149 let current = context.current();
150 let selected = current.as_deref() == Some(value.as_str());
151 let stop = context.focus.read().map_or(selected, |focus| focus == key);
153 let base = context.base.read().clone();
154 let id = tab_id(&base, &value);
155 let panel = format!("{id}-panel");
156 let state = availability_attributes(&availability);
157 let own = value.clone();
158 let mut choose = move |value: String| {
159 context.focus.set(None);
160 context.selected.set(Some(value));
161 };
162 rsx! {
163 button { r#type: "button", class: BEM.element("tab"), role: "tab", id,
164 aria_selected: selected.to_string(), aria_controls: selected.then_some(panel),
165 tabindex: if stop { "0" } else { "-1" },
166 onmounted: move |event| roster.mount(key, event.data()),
167 onclick: move |_| if enabled { choose(own.clone()); },
168 onkeydown: move |event| {
169 let key_pressed = event.key();
170 let activates = key_pressed == Key::Enter || key_pressed == Key::Character(" ".into());
171 if context.activation == Activation::Manual && activates {
172 if enabled {
173 event.prevent_default();
174 choose(value.clone());
175 }
176 return;
177 }
178 let Some(step) = roving::intent(&key_pressed, context.orientation, direction) else { return };
179 let members = roster.peek();
180 let Some(index) = members.iter().position(|m| m.key == key) else { return };
181 let Some(next) = roving::target(members.len(), index, step, |i| members.get(i).is_some_and(|m| m.enabled)).and_then(|i| members.get(i)) else { return };
182 event.prevent_default();
183 if context.activation == Activation::Manual {
184 context.focus.set(Some(next.key));
185 } else {
186 choose(next.value.clone());
187 }
188 roving::focus(next);
189 },
190 ..state,
191 {children}
192 if let Some(count) = count { span { class: BEM.element("count"), "{count}" } }
193 }
194 }
195}
196
197#[component]
199pub fn TabPanel(value: String, children: Element) -> Element {
200 let Some(context) = try_consume_context::<TabsContext>() else {
201 return rsx! {};
202 };
203 if context.current().as_deref() != Some(value.as_str()) {
204 return rsx! {};
205 }
206 let base = context.base.read().clone();
207 let tab = tab_id(&base, &value);
208 rsx! {
209 div { class: BEM.element("panel"), role: "tabpanel", id: "{tab}-panel", aria_labelledby: "{tab}", tabindex: "0",
210 {children}
211 }
212 }
213}