Skip to main content

fc_dioxus/components/
toolbar.rs

1//! A toolbar on the WAI-ARIA keyboard contract, from toolbar.css. A port of upstream's
2//! `toolbar.ts`: one tab stop, arrows within, Home and End to the ends; natively disabled
3//! items are skipped, while `aria-disabled` ones stay reachable so they can be discovered.
4use crate::{
5    behavior::roving::{self, use_member, Roster},
6    core::{
7        assets,
8        attrs::{availability_attributes, component_attributes},
9    },
10    prelude::*,
11    theme::use_stylesheets,
12};
13const BEM: Bem = Bem::new("fc-toolbar");
14const ITEM: Bem = Bem::new("fc-toolbar__item");
15fc_modifier! {
16    /// Toolbar surfaces.
17    pub enum ToolbarSurface for BEM {
18        /// Inline with the content.
19        Plain => None,
20        /// A floating bar with its own surface, e.g. over a selection.
21        Floating => "floating",
22        /// Docked to an edge of its container.
23        Docked => "docked",
24    }
25}
26fc_modifier! { enum ItemShape for ITEM { Text => None, Icon => "icon" } }
27
28#[derive(Clone, Copy)]
29struct ToolbarContext {
30    stop: Signal<Option<u64>>,
31    orientation: Orientation,
32}
33
34/// A named group of controls that is a single tab stop.
35#[component]
36pub fn Toolbar(
37    label: AccessibleName,
38    #[props(default)] orientation: Orientation,
39    #[props(default)] surface: ToolbarSurface,
40    #[props(default)] class: Option<String>,
41    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
42    children: Element,
43) -> Element {
44    // Items join the roster; the context carries the shared focus stop.
45    use_context_provider(Roster::new);
46    use_context_provider(|| ToolbarContext {
47        stop: Signal::new(None),
48        orientation,
49    });
50    let attributes = component_attributes(
51        attributes,
52        ClassList::new(BEM.block())
53            .with_modifier(BEM, surface)
54            .extend(class.as_deref()),
55        &["role", "aria-label", "aria-orientation"],
56    );
57    rsx! {
58        if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_TOOLBAR_CSS } }
59        div { role: "toolbar", aria_label: label.to_string(),
60            aria_orientation: (orientation == Orientation::Vertical).then_some("vertical"),
61            ..attributes, {children}
62        }
63    }
64}
65
66/// A button in a [`Toolbar`]. Icon-only items take `icon_label`.
67#[component]
68pub fn ToolbarItem(
69    #[props(default)] icon_label: Option<AccessibleName>,
70    #[props(default)] pressed: Option<bool>,
71    #[props(default)] availability: Availability,
72    #[props(default)] onclick: Option<EventHandler<MouseEvent>>,
73    children: Element,
74) -> Element {
75    let focusable = availability != Availability::Disabled;
76    let joined = use_member("", focusable, availability == Availability::Enabled);
77    let direction = use_direction();
78    let context = try_consume_context::<ToolbarContext>();
79    let class = ClassList::new(ITEM.block())
80        .with_modifier(
81            ITEM,
82            if icon_label.is_some() {
83                ItemShape::Icon
84            } else {
85                ItemShape::Text
86            },
87        )
88        .to_string();
89    let state = availability_attributes(&availability);
90    let onclick = availability.guard(onclick);
91    let (Some(mut context), Some((roster, key))) = (context, joined) else {
92        return rsx! {};
93    };
94    // The stop is the item last used, or the first focusable one until then.
95    let stop = match *context.stop.read() {
96        Some(stop) => stop == key,
97        None => roster.read().iter().find(|m| m.focusable).map(|m| m.key) == Some(key),
98    };
99    rsx! {
100        button { r#type: "button", class, aria_label: icon_label.map(|l| l.to_string()),
101            aria_pressed: pressed.map(|p| p.to_string()),
102            tabindex: if stop { "0" } else { "-1" },
103            onmounted: move |event| roster.mount(key, event.data()),
104            onfocus: move |_| context.stop.set(Some(key)),
105            onclick: move |event| if let Some(onclick) = onclick { onclick.call(event); },
106            onkeydown: move |event| {
107                let Some(step) = roving::intent(&event.key(), context.orientation, direction) else { return };
108                let members = roster.peek();
109                let Some(index) = members.iter().position(|m| m.key == key) else { return };
110                let Some(next) = roving::target(members.len(), index, step, |i| members.get(i).is_some_and(|m| m.focusable)).and_then(|i| members.get(i)) else { return };
111                event.prevent_default();
112                context.stop.set(Some(next.key));
113                roving::focus(next);
114            },
115            ..state, {children}
116        }
117    }
118}
119
120/// A visible text label inside a toolbar item, set in the UI face.
121#[component]
122pub fn ToolbarLabel(children: Element) -> Element {
123    rsx! { span { class: BEM.element("label"), {children} } }
124}
125
126/// A separator between groups of toolbar items.
127#[component]
128pub fn ToolbarDivider() -> Element {
129    rsx! { span { class: BEM.element("divider"), role: "separator" } }
130}