From 684cdd2a3b842e3d1349bece199b4937a413ae6e Mon Sep 17 00:00:00 2001 From: Michal Pokrywka Date: Fri, 23 Oct 2026 14:06:21 +0200 Subject: [PATCH] feat: MultiSelect with params --- CHANGES.md | 15 +++ Cargo.lock | 67 +++++++--- e2e/tests/storybook/main.rs | 1 + e2e/tests/storybook/multi_select.rs | 63 ++++++++- src/lib.rs | 5 +- src/select/multi_drop_down.rs | 1 + src/select/multi_select.rs | 196 +++++++++++++++++++++++----- storybook/src/multi_select.rs | 25 +++- 8 files changed, 325 insertions(+), 48 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 4a864f5..a8c31fe 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,21 @@ +## Unreleased + +### Added + +* `MultiSelect` takes `params` (`MultiSelectParams`): `MultiSelectMode::Exclusive`, where a click + selects only the clicked option and Ctrl/⌘+click adds it or takes it away, as in + a ``. + Exclusive, +} + +impl MultiSelectMode { + /// Changes `value` as a click on `item` does, with Ctrl or ⌘ held when `modifier`. + fn click(self, value: &mut Vec, item: &T, modifier: bool) { + if self == Self::Exclusive && !modifier { + *value = vec![item.clone()]; + } else if let Some(idx) = value.iter().position(|i| i == item) { + value.remove(idx); + } else { + value.push(item.clone()); + } + } +} + +#[derive(Clone)] +pub struct MultiSelectParams { + pub mode: MultiSelectMode, + /// Label of a button before the options, which clears the selection and is lit while none + /// of the options is selected - f. ex. "All" of a filter, where nothing chosen means + /// nothing filtered out. + pub none_label: Option, + /// The container of the buttons + pub list_css: Css, + /// Every button + pub button_css: Css, + /// Added to the button of a selected option + pub selected_css: Css, + /// Added to the button of an option not selected + pub unselected_css: Css, +} + +impl Default for MultiSelectParams { + fn default() -> Self { + Self { + mode: MultiSelectMode::default(), + none_label: None, + list_css: css! {" + display: flex; + flex-wrap: wrap; + "}, + button_css: Css::default(), + selected_css: css! {" + border-style: inset; + font-weight: bold; + color: green; + "}, + unselected_css: Css::default(), + } + } +} + /// Select component based on vector of `T` values, /// which allows to have multiple options selected at once. /// +/// Every option is a button, with `aria-pressed` telling whether it is selected. +/// /// Example: /// ``` /// use vertigo::{DomNode, dom, Value}; -/// use vertigo_forms::MultiSelect; +/// use vertigo_forms::{MultiSelect, MultiSelectMode, MultiSelectParams}; /// /// let value = Value::new(vec!["foo".to_string()]); /// let options = Value::new( @@ -23,13 +88,28 @@ use vertigo::{ /// dom! { /// +/// }; +/// +/// // A filter: a click shows only one option, Ctrl+click adds another +/// dom! { +/// /// }; /// ``` pub struct MultiSelect { pub value: Value>, pub options: Computed>, + pub params: MultiSelectParams, } impl MultiSelect @@ -41,53 +121,109 @@ where } pub fn mount(&self) -> DomNode { - let Self { value, options } = self; - let toggle = bind_rc!(value, |item: &T| { - value.change(|value| { - if let Some(idx) = value.iter().position(|i| i == item) { - value.remove(idx); + let Self { + value, + options, + params, + } = self; + let mode = params.mode; + let click = { + let value = value.clone(); + Rc::new(move |item: &T, modifier: bool| { + value.change(|value| mode.click(value, item, modifier)); + }) + }; + let button_css = { + let MultiSelectParams { + button_css, + selected_css, + unselected_css, + .. + } = params.clone(); + move |selected: bool| { + button_css.clone().extend(if selected { + selected_css.clone() } else { - value.push(item.clone()); - } - }); + unselected_css.clone() + }) + } + }; + + let none = params.none_label.clone().map(|label| { + let selected = computed_tuple!(value, options) + .map(|(value, options)| !options.iter().any(|option| value.contains(option))); + let on_click = bind!(value, |_| value.set(Vec::new())); + dom! { + + } }); let list = render_list(options, |item| item.to_string(), { - let toggle = toggle.clone(); let value = value.clone(); move |item: &Computed| { let item = item.clone(); let text_item = item.map(|item| item.to_string()); - let on_click = bind!(toggle, item, |_| transaction(|ctx| toggle(&item.get(ctx)))); + let on_click = bind!(click, item, |event: ClickEvent| transaction(|ctx| click( + &item.get(ctx), + event.ctrl_key || event.meta_key + ))); - let css = computed_tuple!(item, value).map(|(item, value)| { - if value.contains(&item) { - css! {" - border-style: inset; - font-weight: bold; - color: green; - "} - } else { - Css::default() - } - }); + let selected = + computed_tuple!(item, value).map(|(item, value)| value.contains(&item)); dom! { - + } } }); - let list_css = css! {" - display: flex; - flex-wrap: wrap; - "}; - dom! { -
+
+ {..none} {list}
} } } + +#[cfg(test)] +mod tests { + use super::MultiSelectMode; + + fn clicked(mode: MultiSelectMode, value: &[&str], item: &str, modifier: bool) -> Vec { + let mut value = value.iter().map(|v| v.to_string()).collect(); + mode.click(&mut value, &item.to_string(), modifier); + value + } + + #[test] + fn toggle_adds_and_takes_away() { + let toggle = MultiSelectMode::Toggle; + assert_eq!(clicked(toggle, &["foo"], "bar", false), ["foo", "bar"]); + assert_eq!(clicked(toggle, &["foo", "bar"], "foo", false), ["bar"]); + assert_eq!(clicked(toggle, &["foo"], "bar", true), ["foo", "bar"]); + } + + #[test] + fn exclusive_selects_only_the_clicked_option() { + let exclusive = MultiSelectMode::Exclusive; + assert_eq!(clicked(exclusive, &["foo", "bar"], "baz", false), ["baz"]); + assert_eq!(clicked(exclusive, &["foo", "bar"], "foo", false), ["foo"]); + assert_eq!(clicked(exclusive, &[], "foo", false), ["foo"]); + } + + #[test] + fn exclusive_with_a_modifier_adds_and_takes_away() { + let exclusive = MultiSelectMode::Exclusive; + assert_eq!(clicked(exclusive, &["foo"], "bar", true), ["foo", "bar"]); + assert_eq!(clicked(exclusive, &["foo", "bar"], "foo", true), ["bar"]); + assert!(clicked(exclusive, &["foo"], "foo", true).is_empty()); + } +} diff --git a/storybook/src/multi_select.rs b/storybook/src/multi_select.rs index bd188c3..6cba6fc 100644 --- a/storybook/src/multi_select.rs +++ b/storybook/src/multi_select.rs @@ -1,5 +1,5 @@ use vertigo::{DomNode, Value, dom}; -use vertigo_forms::MultiSelect; +use vertigo_forms::{MultiSelect, MultiSelectMode, MultiSelectParams}; pub fn multi_select() -> DomNode { let value = Value::new(vec!["foo".to_string()]); @@ -9,16 +9,39 @@ pub fn multi_select() -> DomNode { "baz".to_string(), ]); + let filter = Value::new(vec!["red".to_string()]); + let colors = Value::new(vec![ + "red".to_string(), + "green".to_string(), + "blue".to_string(), + ]); + dom! {
"Select value: "

"Selected values: " {value.map(|v| v.join(","))}

+
+ "Filter, Ctrl+click for more: " + +
+

+ "Filtered by: " {filter.map(|v| if v.is_empty() { "all".to_string() } else { v.join(",") })} +

} }