Skip to main content

fc_dioxus/components/
check.rs

1//! Checkboxes, radios, switches, and their groups, from check.css.
2use crate::{
3    core::{
4        assets,
5        attrs::{availability_attributes, component_attributes},
6        platform::DomBridge,
7    },
8    prelude::*,
9    theme::use_stylesheets,
10};
11const CHECK: Bem = Bem::new("fc-check");
12const GROUP: Bem = Bem::new("fc-check-group");
13const SWITCH: Bem = Bem::new("fc-switch");
14const ROW: Bem = Bem::new("fc-switch-row");
15fc_modifier! { enum GroupFlow for GROUP { Stacked => None, Inline => "inline" } }
16fc_modifier! {
17    /// Switch sizes.
18    pub enum SwitchSize for SWITCH {
19        /// Default.
20        Md => None,
21        /// Small.
22        Sm => "sm",
23        /// Large.
24        Lg => "lg",
25    }
26}
27
28/// A checkbox's value, including the mixed state of a parent over partly checked children.
29#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
30pub enum CheckState {
31    /// Unchecked.
32    #[default]
33    Off,
34    /// Checked.
35    On,
36    /// Some but not all of what it governs is checked.
37    Mixed,
38}
39
40fn stylesheet() -> Element {
41    rsx! { if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_CHECK_CSS } } }
42}
43
44/// A labelled native checkbox.
45///
46/// Pass `checked` to control it, or `default_checked` to let it hold its own state.
47/// `onchange` receives the new checked value; a mixed checkbox becomes checked when
48/// clicked, as the platform does.
49#[component]
50pub fn Checkbox(
51    label: Element,
52    #[props(default)] hint: Option<Element>,
53    #[props(default)] checked: Option<ReadSignal<CheckState>>,
54    #[props(default)] default_checked: CheckState,
55    #[props(default)] onchange: Option<EventHandler<bool>>,
56    #[props(default)] invalid: bool,
57    #[props(default)] availability: Availability,
58    #[props(default)] class: Option<String>,
59    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
60) -> Element {
61    let state = use_controllable(
62        checked,
63        || default_checked,
64        Some(EventHandler::new(move |next: CheckState| {
65            if let Some(onchange) = onchange {
66                onchange.call(next == CheckState::On);
67            }
68        })),
69    );
70    let id = use_id();
71    let current = state.get();
72    let mixed = current == CheckState::Mixed;
73    let effect_id = id.clone();
74    use_effect(use_reactive!(|mixed| {
75        let id = effect_id.clone();
76        spawn(async move {
77            // HTML forbids aria-checked on a native checkbox; the indeterminate property is
78            // the only way to say "mixed", so hosts without a DOM render it unchecked.
79            let _ = DomBridge.set_indeterminate(&id, mixed).await;
80        });
81    }));
82    let blocked = availability != Availability::Enabled;
83    let attributes = component_attributes(
84        attributes,
85        ClassList::new(CHECK.block()).extend(class.as_deref()),
86        &[],
87    );
88    let state_attrs = availability_attributes(&availability);
89    rsx! {
90        {stylesheet()}
91        label { ..attributes,
92            input { id: "{id}", class: CHECK.element("input"), r#type: "checkbox",
93                checked: current == CheckState::On,
94                aria_invalid: invalid.then_some("true"),
95                onclick: move |event| if blocked { event.prevent_default(); },
96                onchange: move |event| if !blocked {
97                    state.set(if event.checked() { CheckState::On } else { CheckState::Off });
98                },
99                ..state_attrs,
100            }
101            span { class: CHECK.element("label"), {label} }
102            if let Some(hint) = hint { span { class: CHECK.element("hint"), {hint} } }
103        }
104    }
105}
106
107/// The radios in a [`CheckGroup`] share one name and one selected value.
108#[derive(Debug, Clone, PartialEq)]
109pub struct RadioChoice {
110    /// The shared input name; use [`use_id`] for a unique one.
111    pub name: Id,
112    /// The selected value, if any.
113    pub value: Option<String>,
114    /// Called with the newly selected value.
115    pub onchange: Option<EventHandler<String>>,
116}
117
118/// A fieldset with a legend, holding checkboxes or, with `radios`, a radio group.
119#[component]
120pub fn CheckGroup(
121    legend: Element,
122    #[props(default)] inline: bool,
123    #[props(default)] radios: Option<RadioChoice>,
124    #[props(default)] class: Option<String>,
125    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
126    children: Element,
127) -> Element {
128    let mut context = use_context_provider(|| Signal::new(radios.clone()));
129    if *context.peek() != radios {
130        context.set(radios);
131    }
132    let attributes = component_attributes(
133        attributes,
134        ClassList::new(GROUP.block())
135            .with_modifier(
136                GROUP,
137                if inline {
138                    GroupFlow::Inline
139                } else {
140                    GroupFlow::Stacked
141                },
142            )
143            .extend(class.as_deref()),
144        &[],
145    );
146    rsx! {
147        {stylesheet()}
148        fieldset { ..attributes,
149            legend { class: GROUP.element("legend"), {legend} }
150            {children}
151        }
152    }
153}
154
155/// Shares radio state with the [`Radio`]s and [`Choice`]s inside, without drawing a
156/// group, for layouts that bring their own fieldset.
157///
158/// [`Choice`]: super::field::Choice
159#[component]
160pub fn RadioScope(radios: RadioChoice, children: Element) -> Element {
161    let mut context = use_context_provider(|| Signal::new(Some(radios.clone())));
162    if context.peek().as_ref() != Some(&radios) {
163        context.set(Some(radios));
164    }
165    rsx! { {children} }
166}
167
168/// One choice in a [`CheckGroup`] given `radios`.
169#[component]
170pub fn Radio(
171    label: Element,
172    value: String,
173    #[props(default)] hint: Option<Element>,
174    #[props(default)] availability: Availability,
175    #[props(default)] class: Option<String>,
176    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
177) -> Element {
178    let choice = try_consume_context::<Signal<Option<RadioChoice>>>()
179        .and_then(|context| context.read().clone());
180    let name = choice.as_ref().map(|c| c.name.to_string());
181    let checked = choice
182        .as_ref()
183        .is_some_and(|c| c.value.as_deref() == Some(value.as_str()));
184    let onchange = choice.and_then(|c| availability.guard(c.onchange));
185    let blocked = availability != Availability::Enabled;
186    let attributes = component_attributes(
187        attributes,
188        ClassList::new(CHECK.block()).extend(class.as_deref()),
189        &[],
190    );
191    let state_attrs = availability_attributes(&availability);
192    rsx! {
193        {stylesheet()}
194        label { ..attributes,
195            input { class: CHECK.element("input"), r#type: "radio", name, value: value.clone(), checked,
196                onclick: move |event| if blocked { event.prevent_default(); },
197                onchange: move |_| if let Some(onchange) = onchange { onchange.call(value.clone()); },
198                ..state_attrs,
199            }
200            span { class: CHECK.element("label"), {label} }
201            if let Some(hint) = hint { span { class: CHECK.element("hint"), {hint} } }
202        }
203    }
204}
205
206/// How a standalone control gets its accessible name.
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub enum ControlName {
209    /// Named directly, with `aria-label`.
210    Named(AccessibleName),
211    /// Named by the `<label>` that wraps it, such as a [`ListAction::Label`] row.
212    ///
213    /// [`ListAction::Label`]: super::list::ListAction::Label
214    Wrapped,
215}
216impl From<AccessibleName> for ControlName {
217    fn from(name: AccessibleName) -> Self {
218        Self::Named(name)
219    }
220}
221
222#[component]
223fn SwitchInput(
224    label: ControlName,
225    size: SwitchSize,
226    checked: bool,
227    onchange: EventHandler<bool>,
228    invalid: bool,
229    availability: Availability,
230    class: Option<String>,
231    attributes: Vec<Attribute>,
232) -> Element {
233    let blocked = availability != Availability::Enabled;
234    let mut attributes = component_attributes(
235        attributes,
236        ClassList::new(SWITCH.block())
237            .with_modifier(SWITCH, size)
238            .extend(class.as_deref()),
239        &["type", "role", "checked", "aria-label"],
240    );
241    attributes.extend(availability_attributes(&availability));
242    rsx! {
243        input { r#type: "checkbox", role: "switch", checked,
244            aria_label: match label { ControlName::Named(name) => Some(name.to_string()), ControlName::Wrapped => None },
245            aria_invalid: invalid.then_some("true"),
246            onclick: move |event| if blocked { event.prevent_default(); },
247            onchange: move |event| if !blocked { onchange.call(event.checked()); },
248            ..attributes,
249        }
250    }
251}
252
253/// An on/off control that takes effect immediately, as `<input type=checkbox role=switch>`.
254#[component]
255pub fn Switch(
256    #[props(into)] label: ControlName,
257    #[props(default)] size: SwitchSize,
258    #[props(default)] checked: Option<ReadSignal<bool>>,
259    #[props(default)] default_checked: bool,
260    #[props(default)] onchange: Option<EventHandler<bool>>,
261    #[props(default)] invalid: bool,
262    #[props(default)] availability: Availability,
263    #[props(default)] class: Option<String>,
264    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
265) -> Element {
266    let state = use_controllable(checked, || default_checked, onchange);
267    rsx! {
268        {stylesheet()}
269        SwitchInput { label, size, checked: state.get(),
270            onchange: move |value| state.set(value), invalid, availability, class, attributes }
271    }
272}
273
274/// A settings row: a label and hint on one side, a switch on the other, all one target.
275#[component]
276pub fn SwitchRow(
277    label: Element,
278    #[props(default)] hint: Option<Element>,
279    #[props(default)] size: SwitchSize,
280    #[props(default)] checked: Option<ReadSignal<bool>>,
281    #[props(default)] default_checked: bool,
282    #[props(default)] onchange: Option<EventHandler<bool>>,
283    #[props(default)] availability: Availability,
284    #[props(default)] class: Option<String>,
285    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
286) -> Element {
287    let state = use_controllable(checked, || default_checked, onchange);
288    let attributes = component_attributes(
289        attributes,
290        ClassList::new(ROW.block()).extend(class.as_deref()),
291        &[],
292    );
293    rsx! {
294        {stylesheet()}
295        label { ..attributes,
296            span { class: ROW.element("text"),
297                span { class: ROW.element("label"), {label} }
298                if let Some(hint) = hint { span { class: ROW.element("hint"), {hint} } }
299            }
300            SwitchInput { label: ControlName::Wrapped, size, checked: state.get(), onchange: move |value| state.set(value),
301                invalid: false, availability, class: None, attributes: Vec::new() }
302        }
303    }
304}