Skip to main content

fc_dioxus/components/
multiselect.rs

1//! A disclosure of checkboxes with filtering and bulk actions, from multiselect.css. A
2//! port of upstream's `multiselect.ts`.
3use crate::{
4    core::{
5        assets,
6        attrs::{
7            availability_attributes, component_attributes, count_attribute, multiselect_part,
8            selected_attribute, FcData, Kind, MultiselectPart,
9        },
10    },
11    prelude::*,
12    theme::use_stylesheets,
13};
14const BEM: Bem = Bem::new("fc-multiselect");
15const FIELD: Bem = Bem::new("fc-field");
16const LABEL: Bem = Bem::new("fc-field__label");
17const INPUT: Bem = Bem::new("fc-input");
18const CHECK: Bem = Bem::new("fc-check__input");
19const BTN: Bem = Bem::new("fc-btn");
20
21/// One choice.
22#[derive(Debug, Clone, PartialEq)]
23pub struct MultiOption {
24    /// The submitted value; unique within the picker.
25    pub value: String,
26    /// What people read.
27    pub title: String,
28    /// A second line, e.g. a role.
29    pub description: Option<String>,
30    /// Unavailable choices keep their state: bulk actions skip them.
31    pub availability: Availability,
32}
33
34/// Which options a filter query keeps, by case-insensitive substring of their text, as
35/// upstream's `filter` does.
36///
37/// ```
38/// use fc_dioxus::components::multiselect::matches;
39/// assert!(matches("Ada Lovelace Maintainer", "  ada "));
40/// assert!(!matches("Grace Hopper", "ada"));
41/// ```
42#[must_use]
43pub fn matches(text: &str, query: &str) -> bool {
44    text.to_lowercase().contains(&query.trim().to_lowercase())
45}
46
47/// The summary upstream shows by default: "None selected" or "n selected".
48#[must_use]
49pub fn default_summary(count: usize) -> String {
50    if count == 0 {
51        "None selected".to_owned()
52    } else {
53        format!("{count} selected")
54    }
55}
56
57/// Native checkboxes in a `<details>`, so choosing and form submission work without
58/// script; filtering, bulk actions, and the summary are added on top. Escape closes the
59/// panel and returns focus to its summary.
60#[component]
61pub fn MultiSelect(
62    /// What is being chosen, shown in the summary, e.g. "Project members".
63    label: Element,
64    /// The fieldset's legend.
65    legend: Element,
66    /// Shared `name` for form submission.
67    name: String,
68    options: Vec<MultiOption>,
69    #[props(default)] values: Option<ReadSignal<Vec<String>>>,
70    #[props(default)] default_values: Vec<String>,
71    #[props(default)] onchange: Option<EventHandler<Vec<String>>>,
72    /// Label for the filter field; no filter when omitted.
73    #[props(default)]
74    filter_label: Option<String>,
75    #[props(default)] filter_placeholder: Option<String>,
76    #[props(default)] open: bool,
77    /// Summary text for a count; [`default_summary`] when omitted.
78    #[props(default)]
79    summary: Option<Callback<usize, String>>,
80    /// Mirror the selection count into the `data-fc-*` contract under this id.
81    #[props(default)]
82    fc_id: Option<Id>,
83    #[props(default)] class: Option<String>,
84    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
85) -> Element {
86    let state = use_controllable(values, || default_values, onchange);
87    let mut query = use_signal(String::new);
88    // The details element is controlled: the summary's activation toggles this signal
89    // instead of the native state, so Escape can close it too. Toggle events are not
90    // used, because the browser also fires one for a details that starts open.
91    let mut expanded = use_signal(|| open);
92    // `open` stays the parent's to change after mount; the user's own toggles live in
93    // `expanded` until the parent next says otherwise.
94    let mut seen_open = use_signal(|| open);
95    if *seen_open.peek() != open {
96        seen_open.set(open);
97        expanded.set(open);
98    }
99    let mut summary_node = use_signal(|| None::<std::rc::Rc<MountedData>>);
100    let chosen = state.get();
101    let mut attributes = component_attributes(
102        attributes,
103        ClassList::new(BEM.block()).extend(class.as_deref()),
104        &["open"],
105    );
106    if let Some(fc_id) = fc_id {
107        attributes.extend(
108            FcData {
109                kind: Some(Kind::Multiselect),
110                id: Some(fc_id),
111                state: Some(Lifecycle::Complete),
112                ..FcData::default()
113            }
114            .attributes(),
115        );
116        attributes.push(count_attribute(chosen.len()));
117    }
118    let text = summary.map_or_else(|| default_summary(chosen.len()), |s| s.call(chosen.len()));
119    let visible: Vec<bool> = options
120        .iter()
121        .map(|o| {
122            matches(
123                &format!("{} {}", o.title, o.description.clone().unwrap_or_default()),
124                &query(),
125            )
126        })
127        .collect();
128    let none_visible = !visible.iter().any(|v| *v);
129    let toggle = move |value: String, on: bool| {
130        let mut next: Vec<String> = state.get().into_iter().filter(|v| *v != value).collect();
131        if on {
132            next.push(value);
133        }
134        state.set(next);
135    };
136    let editable: Vec<(String, bool)> = options
137        .iter()
138        .zip(&visible)
139        .filter(|(o, _)| o.availability == Availability::Enabled)
140        .map(|(o, v)| (o.value.clone(), *v))
141        .collect();
142    let select_all = {
143        let editable = editable.clone();
144        move |_| {
145            let mut next = state.get();
146            for (value, visible) in &editable {
147                if *visible && !next.contains(value) {
148                    next.push(value.clone());
149                }
150            }
151            state.set(next);
152        }
153    };
154    let clear = move |_| {
155        let keep: Vec<String> = state
156            .get()
157            .into_iter()
158            .filter(|v| !editable.iter().any(|(e, _)| e == v))
159            .collect();
160        state.set(keep);
161    };
162    rsx! {
163        if use_stylesheets() {
164            document::Stylesheet { href: assets::COMPONENTS_MULTISELECT_CSS }
165            document::Stylesheet { href: assets::COMPONENTS_CHECK_CSS }
166            document::Stylesheet { href: assets::COMPONENTS_FIELD_CSS }
167            document::Stylesheet { href: assets::COMPONENTS_BUTTON_CSS }
168        }
169        details { open: expanded(),
170            onkeydown: move |event| {
171                if event.key() == Key::Escape && expanded() {
172                    event.prevent_default();
173                    expanded.set(false);
174                    if let Some(summary) = summary_node() {
175                        spawn(async move { let _ = summary.set_focus(true).await; });
176                    }
177                }
178            },
179            ..attributes,
180            summary { class: BEM.element("summary"), onmounted: move |event| summary_node.set(Some(event.data())),
181                onclick: move |event| { event.prevent_default(); expanded.toggle(); },
182                {label} " ยท " span { ..crate::spread![multiselect_part(MultiselectPart::Summary)], "{text}" }
183            }
184            div { class: BEM.element("panel"),
185                if let Some(filter_label) = filter_label {
186                    label { class: FIELD.block(),
187                        span { class: LABEL.block(), "{filter_label}" }
188                        input { class: INPUT.block(), r#type: "search", placeholder: filter_placeholder,
189                            value: query(), oninput: move |event| query.set(event.value()),
190                            ..crate::spread![multiselect_part(MultiselectPart::Filter)] }
191                    }
192                }
193                div { class: BEM.element("actions"),
194                    button { class: ClassList::new(BTN.block()).with(BTN.modifier_raw("secondary")).with(BTN.modifier_raw("sm")).to_string(),
195                        r#type: "button", onclick: select_all, ..crate::spread![multiselect_part(MultiselectPart::SelectAll)], "Select visible" }
196                    button { class: ClassList::new(BTN.block()).with(BTN.modifier_raw("tertiary")).with(BTN.modifier_raw("sm")).to_string(),
197                        r#type: "button", onclick: clear, ..crate::spread![multiselect_part(MultiselectPart::Clear)], "Clear" }
198                }
199                fieldset { class: BEM.element("options"),
200                    legend { {legend} }
201                    for (option, shown) in options.iter().zip(visible.iter().copied()) {
202                        {option_view(option, shown, chosen.contains(&option.value), &name, toggle)}
203                    }
204                }
205                p { hidden: !none_visible, ..crate::spread![multiselect_part(MultiselectPart::Empty)], "No matches. Existing selections are kept." }
206            }
207        }
208    }
209}
210
211fn option_view(
212    option: &MultiOption,
213    shown: bool,
214    checked: bool,
215    name: &str,
216    mut toggle: impl FnMut(String, bool) + 'static,
217) -> Element {
218    let blocked = option.availability != Availability::Enabled;
219    let state = availability_attributes(&option.availability);
220    let value = option.value.clone();
221    rsx! {
222        label { key: "{option.value}", class: BEM.element("option"), hidden: !shown, ..crate::spread![selected_attribute(checked)],
223            input { class: CHECK.block(), r#type: "checkbox", name: "{name}", value: "{option.value}", checked,
224                onclick: move |event| if blocked { event.prevent_default(); },
225                onchange: move |event| if !blocked { toggle(value.clone(), event.checked()); },
226                ..state,
227            }
228            span { class: BEM.element("content"),
229                strong { "{option.title}" }
230                if let Some(description) = &option.description { span { class: BEM.element("description"), "{description}" } }
231            }
232        }
233    }
234}