1use 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#[derive(Debug, Clone, PartialEq)]
23pub struct MultiOption {
24 pub value: String,
26 pub title: String,
28 pub description: Option<String>,
30 pub availability: Availability,
32}
33
34#[must_use]
43pub fn matches(text: &str, query: &str) -> bool {
44 text.to_lowercase().contains(&query.trim().to_lowercase())
45}
46
47#[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#[component]
61pub fn MultiSelect(
62 label: Element,
64 legend: Element,
66 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 #[props(default)]
74 filter_label: Option<String>,
75 #[props(default)] filter_placeholder: Option<String>,
76 #[props(default)] open: bool,
77 #[props(default)]
79 summary: Option<Callback<usize, String>>,
80 #[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 let mut expanded = use_signal(|| open);
92 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}