Skip to main content

fc_dioxus/components/
button.rs

1//! Native actions styled by upstream's button.css.
2use crate::{
3    core::{
4        assets,
5        attrs::{availability_attributes, component_attributes},
6    },
7    prelude::*,
8    theme::use_stylesheets,
9};
10
11const BEM: Bem = Bem::new("fc-btn");
12fc_modifier! {
13    /// All upstream button styles, including semantic aliases.
14    pub enum ButtonVariant for BEM {
15        /// Unaccented native action.
16        Neutral => None,
17        /// Main action.
18        Primary => "primary",
19        /// Outlined supporting action.
20        Secondary => "secondary",
21        /// Transparent action.
22        Ghost => "ghost",
23        /// Alias of ghost.
24        Tertiary => "tertiary",
25        /// Alias of primary.
26        Ok => "ok",
27        /// Alias of secondary.
28        Cancel => "cancel",
29        /// Tinted action.
30        Subtle => "subtle",
31        /// Destructive action requiring consumer confirmation.
32        Danger => "danger",
33    }
34}
35fc_modifier! {
36    /// Button target size.
37    pub enum ButtonSize for BEM {
38        /// Standard target.
39        Md => None,
40        /// Compact target.
41        Sm => "sm",
42        /// Large target.
43        Lg => "lg",
44    }
45}
46fc_modifier! { enum ButtonWidth for BEM { Auto => None, Block => "block" } }
47fc_modifier! { enum ButtonShape for BEM { Text => None, Icon => "icon" } }
48/// Native form behavior; ordinary actions do not submit by default.
49#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
50#[non_exhaustive]
51pub enum ButtonType {
52    /// Ordinary action.
53    #[default]
54    Button,
55    /// Submit the containing form.
56    Submit,
57    /// Reset the containing form.
58    Reset,
59}
60impl ButtonType {
61    fn as_str(self) -> &'static str {
62        match self {
63            Self::Button => "button",
64            Self::Submit => "submit",
65            Self::Reset => "reset",
66        }
67    }
68}
69/// Props for a native button with protected activation state.
70#[derive(Props, Clone, PartialEq)]
71pub struct ButtonProps {
72    /// Visual style.
73    #[props(default)]
74    pub variant: ButtonVariant,
75    /// Target size.
76    #[props(default)]
77    pub size: ButtonSize,
78    /// Fill the container width.
79    #[props(default)]
80    pub block: bool,
81    /// Whether activation is possible.
82    #[props(default)]
83    pub availability: Availability,
84    /// Retain the label, show a spinner, and prevent duplicate activation.
85    #[props(default)]
86    pub busy: bool,
87    /// Optional toggle state.
88    #[props(default)]
89    pub pressed: Option<bool>,
90    /// Form behavior.
91    #[props(default)]
92    pub r#type: ButtonType,
93    /// Select icon-only layout with a required accessible name.
94    #[props(default)]
95    pub icon_label: Option<AccessibleName>,
96    /// Native activation event, guarded for unavailable and busy states.
97    #[props(default)]
98    pub onclick: Option<EventHandler<MouseEvent>>,
99    /// Extra classes.
100    #[props(default)]
101    pub class: Option<String>,
102    /// Consumer native attributes, before owned semantics.
103    #[props(extends = GlobalAttributes, extends = button)]
104    pub attributes: Vec<Attribute>,
105    /// Visible label or decorative icon in icon-only mode.
106    pub children: Element,
107}
108/// A native button that remains named while busy or unavailable.
109#[component]
110pub fn Button(props: ButtonProps) -> Element {
111    let blocked = props.busy || props.availability != Availability::Enabled;
112    let classes = ClassList::new(BEM.block())
113        .with_modifier(BEM, props.variant)
114        .with_modifier(BEM, props.size)
115        .with_modifier(
116            BEM,
117            if props.block {
118                ButtonWidth::Block
119            } else {
120                ButtonWidth::Auto
121            },
122        )
123        .with_modifier(
124            BEM,
125            if props.icon_label.is_some() {
126                ButtonShape::Icon
127            } else {
128                ButtonShape::Text
129            },
130        )
131        .extend(super::group_item_class().as_deref())
132        .extend(props.class.as_deref());
133    let mut attributes = component_attributes(
134        props.attributes,
135        classes,
136        &[
137            "type",
138            "role",
139            "disabled",
140            "aria-disabled",
141            "aria-busy",
142            "aria-pressed",
143            "onclick",
144        ],
145    );
146    attributes.extend(availability_attributes(&props.availability));
147    // Inside a Tooltip, the bubble describes the button too.
148    if let Some(super::popover::DescribedBy(tip)) = try_consume_context() {
149        match attributes
150            .iter_mut()
151            .find(|attr| attr.name == "aria-describedby")
152        {
153            Some(attr) => {
154                if let dioxus::core::AttributeValue::Text(ids) = &attr.value {
155                    attr.value = dioxus::core::AttributeValue::Text(format!("{ids} {tip}"));
156                }
157            }
158            None => attributes.push(Attribute::new(
159                "aria-describedby",
160                tip.to_string(),
161                None,
162                false,
163            )),
164        }
165    }
166    if let Some(label) = &props.icon_label {
167        attributes.retain(|attr| !matches!(attr.name, "aria-label" | "aria-labelledby"));
168        attributes.push(Attribute::new("aria-label", label.to_string(), None, false));
169    }
170    attributes.push(Attribute::new("type", props.r#type.as_str(), None, false));
171    if props.busy {
172        attributes.push(Attribute::new("aria-busy", "true", None, false));
173    }
174    if let Some(pressed) = props.pressed {
175        attributes.push(Attribute::new(
176            "aria-pressed",
177            pressed.to_string(),
178            None,
179            false,
180        ));
181    }
182    rsx! {
183        if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_BUTTON_CSS } }
184        button {
185            onclick: move |event: MouseEvent| {
186                if blocked { event.prevent_default(); event.stop_propagation(); }
187                else if let Some(handler) = props.onclick { handler.call(event); }
188            },
189            ..attributes,
190            if props.busy { span { class: BEM.element("spinner"), aria_hidden: "true" } }
191            if props.busy { span { {props.children} } }
192            else { {props.children} }
193        }
194    }
195}
196
197/// A link drawn as a button, for actions that go somewhere: "View report", "Back to
198/// projects". Inside a `dioxus-router` `Router` (feature `router`), an internal `href`
199/// navigates in-app and is marked current when it is the route being shown.
200///
201/// It stays a link, so it opens in a new tab with a modified click and is announced as
202/// a link. An unavailable link drops its `href` and carries `aria-disabled`, since `<a>`
203/// has no disabled state.
204#[component]
205pub fn LinkButton(
206    href: String,
207    #[props(default)] variant: ButtonVariant,
208    #[props(default)] size: ButtonSize,
209    #[props(default)] block: bool,
210    /// Select icon-only layout with a required accessible name.
211    #[props(default)]
212    icon_label: Option<AccessibleName>,
213    #[props(default)] availability: Availability,
214    /// Called as well as navigating, e.g. to close a menu.
215    #[props(default)]
216    onclick: Option<EventHandler<MouseEvent>>,
217    #[props(default)] class: Option<String>,
218    #[props(extends = GlobalAttributes, extends = a)] attributes: Vec<Attribute>,
219    children: Element,
220) -> Element {
221    let blocked = availability != Availability::Enabled;
222    let link = crate::core::link::use_link((!blocked).then_some(href));
223    let classes = ClassList::new(BEM.block())
224        .with_modifier(BEM, variant)
225        .with_modifier(BEM, size)
226        .with_modifier(
227            BEM,
228            if block {
229                ButtonWidth::Block
230            } else {
231                ButtonWidth::Auto
232            },
233        )
234        .with_modifier(
235            BEM,
236            if icon_label.is_some() {
237                ButtonShape::Icon
238            } else {
239                ButtonShape::Text
240            },
241        )
242        .extend(super::group_item_class().as_deref())
243        .extend(class.as_deref());
244    let mut attributes = component_attributes(
245        attributes,
246        classes,
247        &["href", "aria-disabled", "aria-current", "onclick"],
248    );
249    match &availability {
250        Availability::Enabled => {}
251        Availability::Disabled => {
252            attributes.push(Attribute::new("aria-disabled", "true", None, false));
253        }
254        other => attributes.extend(availability_attributes(other)),
255    }
256    if let Some(label) = &icon_label {
257        attributes.retain(|attr| !matches!(attr.name, "aria-label" | "aria-labelledby"));
258        attributes.push(Attribute::new("aria-label", label.to_string(), None, false));
259    }
260    let href = link.href.clone();
261    let current = link.current;
262    rsx! {
263        if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_BUTTON_CSS } }
264        a { href, aria_current: current.then_some("page"),
265            onclick: move |event: MouseEvent| {
266                if blocked {
267                    event.prevent_default();
268                    return;
269                }
270                link.follow(&event);
271                if let Some(onclick) = onclick { onclick.call(event); }
272            },
273            ..attributes, {children}
274        }
275    }
276}