Skip to main content

fc_dioxus/components/
tabs.rs

1//! Tabs on the APG keyboard contract, from tabs.css. A port of upstream's `tabs.ts`.
2use 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/// Whether arrowing to a tab also selects it.
16#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum Activation {
18    /// Arrowing selects; right when panels are cheap to show.
19    #[default]
20    Automatic,
21    /// Arrowing moves focus only; Enter or Space selects. For panels that fetch.
22    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    /// The selected value, or the first enabled tab's before anything is chosen.
36    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/// A tab list and its panels, paired by value. Ids, `aria-controls`, and
48/// `aria-labelledby` are generated; only the selected panel is rendered.
49///
50/// Without a value or `default_value`, the first enabled tab is selected once the tabs
51/// have rendered; give `default_value` so server-rendered markup includes its panel.
52#[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    /// [`Tab`]s.
62    tabs: Element,
63    /// [`TabPanel`]s.
64    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    // Values are author text. Letters, digits, and `-` pass through; everything else,
119    // `_` included, becomes `_{hex}_`, so distinct values can never share an id
120    // (`a/b` and `a_b` would both have been `a_b`).
121    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/// One tab. `count` adds a badge such as the number of problems.
133#[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    // The focus stop is the selected tab, or wherever manual activation moved it.
152    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/// The content for one tab. Rendered only while its tab is selected.
198#[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}