From: Dominik Csapak <d.csapak@proxmox.com>
To: Nicolas Frey <n.frey@proxmox.com>, yew-devel@lists.proxmox.com
Subject: Re: [PATCH proxmox-yew-widget-toolkit 4/5] widget: form: implement multi select field
Date: Mon, 2 Nov 2026 15:12:18 +0100 [thread overview]
Message-ID: <1d940f6b-355d-46ed-80ba-e8e14f58503b@proxmox.com> (raw)
In-Reply-To: <20261028151517.518742-5-n.frey@proxmox.com>
some high level comments:
I'd really like for this to follow the existing conventions for our
other fields. It's Basically a Combobox but with multiselect, so I'd
like to see either:
* make it an option in combobox `.multiselect(true)`
* or keep it separate but implement the same interface (with_item,
triggers, etc.)
(wheatever is easier)
If it's a separate field, something like 'multicombobox' would
maybe be more fitting? (to convey the combobox base)
On 10/28/26 4:15 PM, Nicolas Frey wrote:
> as stated in the `Combobox` documentation, its implementation does not
> support a multi-select mode. add a separate widget that shows a
> `GridPicker` inside a dropdown, to keep the implementations separate.
>
> values that are not among the items, e.g. loaded from the form or left
> over after the items changed, are kept as extra entries. that way they
> stay visible and are not dropped on the next selection change.
>
> Signed-off-by: Nicolas Frey <n.frey@proxmox.com>
> ---
> src/widget/form/mod.rs | 5 +
> src/widget/form/multi_select.rs | 308 ++++++++++++++++++++++++++++++++
> 2 files changed, 313 insertions(+)
> create mode 100644 src/widget/form/multi_select.rs
>
> diff --git a/src/widget/form/mod.rs b/src/widget/form/mod.rs
> index 2bf030d..14dbcec 100644
> --- a/src/widget/form/mod.rs
> +++ b/src/widget/form/mod.rs
> @@ -85,3 +85,8 @@ pub use date_field::DateField;
> #[doc(hidden)]
> pub use date_field::DateFieldComp;
> pub use date_field::PlainDate;
> +
> +mod multi_select;
> +pub use multi_select::MultiSelect;
> +#[doc(hidden)]
> +pub use multi_select::MultiSelectField;
> diff --git a/src/widget/form/multi_select.rs b/src/widget/form/multi_select.rs
> new file mode 100644
> index 0000000..b8f34a5
> --- /dev/null
> +++ b/src/widget/form/multi_select.rs
> @@ -0,0 +1,308 @@
> +use std::collections::HashSet;
> +use std::rc::Rc;
> +
> +use anyhow::{Error, bail};
> +use serde_json::Value;
> +
> +use crate::{
> + props::{
> + ContainerBuilder, FieldBuilder, IntoOptionalRenderFn, IntoTextFilterFn, RenderFn,
> + TextFilterFn, WidgetBuilder,
> + },
> + state::{Selection, SelectionObserver, Store},
> + tr,
> + widget::{
> + Dropdown, DropdownController, GridPicker, Row,
> + data_table::{DataTable, DataTableColumn, DataTableHeader, MultiSelectMode},
> + form::{
> + ManagedField, ManagedFieldContext, ManagedFieldMaster, ManagedFieldScopeExt,
> + ManagedFieldState,
> + },
> + },
> +};
> +use pwt_macros::{builder, widget};
> +use yew::{AttrValue, Html, Properties, html, html::IntoPropValue, virtual_dom::Key};
> +
> +/// MultiSelect widget
> +///
> +/// Allows to select multiple text options.
> +///
> +/// Select any number of the given items. The value is a json array of the
> +/// selected items, in the order the items were passed.
> +///
> +/// Use [Combobox](super::Combobox) if only one option has to be
> +/// selectable (single-select).
> +#[widget(pwt=crate, comp=ManagedFieldMaster<MultiSelectField>, @input)]
> +#[derive(Clone, Properties, PartialEq)]
> +#[builder]
> +pub struct MultiSelect {
> + /// The selectable items.
> + #[prop_or_default]
> + pub items: Rc<Vec<AttrValue>>,
> +
> + /// The items to select unless the form is populated.
> + #[prop_or_default]
> + #[builder(IntoPropValue, into_prop_value)]
> + pub default: Vec<AttrValue>,
> +
> + /// Display the output of this function instead of displaying values directly.
> + #[builder_cb(IntoOptionalRenderFn, into_optional_render_fn, AttrValue)]
> + #[prop_or_default]
> + pub render_value: Option<RenderFn<AttrValue>>,
> +
> + /// Show filter
> + ///
> + /// Default behavior is to show the filter for pickers with more than 10 items.
> + #[builder(IntoPropValue, into_prop_value)]
> + #[prop_or_default]
> + pub show_filter: Option<bool>,
> +
> + /// Custom filter function.
> + #[builder_cb(IntoTextFilterFn, into_text_filter_fn, AttrValue)]
> + #[prop_or_default]
> + pub filter: Option<TextFilterFn<AttrValue>>,
> +}
> +
> +impl MultiSelect {
> + /// Create a new instance.
> + pub fn new(items: Rc<Vec<AttrValue>>) -> Self {
> + yew::props!(Self { items })
> + }
> +}
> +
> +pub struct MultiSelectField {
> + state: ManagedFieldState,
> + store: Store<AttrValue>,
> + selection: Selection,
> + columns: Rc<Vec<DataTableHeader<AttrValue>>>,
> + /// Values the field was loaded with that are not among the items.
> + extra: Vec<AttrValue>,
> + _selection_observer: SelectionObserver,
> +}
> +
> +crate::impl_deref_mut_property!(MultiSelectField, state, ManagedFieldState);
> +
> +impl MultiSelectField {
> + fn columns(props: &MultiSelect) -> Rc<Vec<DataTableHeader<AttrValue>>> {
> + let render_value = props
> + .render_value
> + .clone()
> + .unwrap_or_else(|| RenderFn::new(|item: &AttrValue| html! { item }));
> +
> + Rc::new(vec![
> + DataTableColumn::<AttrValue>::selection_indicator().into(),
> + DataTableColumn::new("Value")
> + .show_menu(false)
> + .render(render_value)
> + .into(),
> + ])
> + }
> +
> + fn chain_extra(&self, props: &MultiSelect) -> Vec<AttrValue> {
> + props
> + .items
> + .iter()
> + .chain(self.extra.iter())
> + .cloned()
> + .collect()
> + }
> +
> + fn selected(&self, props: &MultiSelect) -> Vec<AttrValue> {
> + self.chain_extra(props)
> + .into_iter()
> + .filter(|item| self.selection.contains(&Key::from(item.as_str())))
> + .collect()
> + }
> +
> + /// Adds the values of `value` that are neither items nor already known extras.
> + fn add_extra(&mut self, props: &MultiSelect, value: &Value) -> bool {
> + let unknown: Vec<AttrValue> = value
> + .as_array()
> + .into_iter()
> + .flatten()
> + .filter_map(|item| item.as_str())
> + .filter(|item| {
> + !props.items.iter().any(|known| known == item)
> + && !self.extra.iter().any(|known| known == item)
> + })
> + .map(|item| AttrValue::from(item.to_string()))
> + .collect();
> +
> + if unknown.is_empty() {
> + return false;
> + }
> + self.extra.extend(unknown);
> + self.store.set_data(self.chain_extra(props));
> + true
> + }
> +}
> +
> +fn selected_keys(value: &Value) -> HashSet<Key> {
> + match value.as_array() {
> + Some(items) => items
> + .iter()
> + .filter_map(|item| item.as_str())
> + .map(|item| Key::from(item.to_string()))
> + .collect(),
> + None => HashSet::new(),
> + }
> +}
> +
> +impl ManagedField for MultiSelectField {
> + type Message = ();
> + type Properties = MultiSelect;
> + type ValidateClosure = bool;
> +
> + fn validation_args(props: &Self::Properties) -> Self::ValidateClosure {
> + props.input_props.required
> + }
> +
> + fn validator(required: &Self::ValidateClosure, value: &Value) -> Result<Value, Error> {
> + let items = match value {
> + Value::Null => Vec::new(),
> + Value::Array(items) if items.iter().all(Value::is_string) => items.clone(),
> + _ => bail!(tr!("got wrong data type.")),
> + };
> +
> + if *required && items.is_empty() {
> + bail!(tr!("Field may not be empty."));
> + }
> +
> + Ok(Value::Array(items))
> + }
> +
> + fn create(ctx: &ManagedFieldContext<Self>) -> Self {
> + let props = ctx.props();
> +
> + let store = Store::with_extract_key(|item: &AttrValue| Key::from(item.as_str()));
> + store.set_data(props.items.as_ref().clone());
> +
> + let selection = Selection::new().multiselect(true);
> + let _selection_observer = selection.add_listener(ctx.link().callback(|_| ()));
> +
> + let default = Value::Array(
> + props
> + .default
> + .iter()
> + .map(|item| Value::String(item.to_string()))
> + .collect(),
> + );
> + selection.bulk_select(selected_keys(&default));
> +
> + let mut this = Self {
> + state: ManagedFieldState::new(default.clone(), default.clone()),
> + store,
> + selection,
> + columns: Self::columns(props),
> + extra: Vec::new(),
> + _selection_observer,
> + };
> + this.add_extra(props, &default);
> + this
> + }
> +
> + fn changed(&mut self, ctx: &ManagedFieldContext<Self>, old_props: &Self::Properties) -> bool {
> + let props = ctx.props();
> +
> + if props.items != old_props.items {
> + // keep selected values that are no longer items, so they stay visible and are not
> + // dropped from the value on the next selection change
> + let selected = selected_keys(&self.value);
> + for item in old_props.items.iter() {
> + if !props.items.contains(item)
> + && !self.extra.contains(item)
> + && selected.contains(&Key::from(item.as_str()))
> + {
> + self.extra.push(item.clone());
> + }
> + }
> + self.extra.retain(|item| !props.items.contains(item));
> + self.store.set_data(self.chain_extra(props));
> + }
> + // rebuild unconditionally, so items whose label loads asynchronously do
> + // not keep the label they had when the field was created
> + self.columns = Self::columns(props);
> +
> + true
> + }
> +
> + fn value_changed(&mut self, ctx: &ManagedFieldContext<Self>) {
> + let value = self.value.clone();
> + self.add_extra(ctx.props(), &value);
> + self.selection.bulk_select(selected_keys(&value));
> + }
> +
> + fn update(&mut self, ctx: &ManagedFieldContext<Self>, _msg: Self::Message) -> bool {
> + let selected = self.selected(ctx.props());
> +
> + // the selection also changes when a value is loaded,
> + // so update only what the user changed
> + let selected_set: HashSet<Key> = selected
> + .iter()
> + .map(|item| Key::from(item.to_string()))
> + .collect();
> + if selected_set == selected_keys(&self.value) {
> + return true;
> + }
> +
> + let value = selected
> + .into_iter()
> + .map(|item| Value::String(item.to_string()))
> + .collect();
> +
> + ctx.link().update_value(Value::Array(value));
> + true
> + }
> +
> + fn view(&self, ctx: &ManagedFieldContext<Self>) -> Html {
> + let props = ctx.props();
> +
> + let store = self.store.clone();
> + let selection = self.selection.clone();
> + let columns = Rc::clone(&self.columns);
> + let show_filter = props.show_filter;
> + let filter = props.filter.clone();
> + let picker = move |_controller: &DropdownController| {
> + let table = DataTable::new(Rc::clone(&columns), store.clone())
> + .striped(false)
> + .borderless(true)
> + .bordered(false)
> + .show_header(false)
> + .multiselect_mode(MultiSelectMode::Simple);
> +
> + GridPicker::new(table)
> + .selection(selection.clone())
> + .show_filter(show_filter)
> + .filter(filter.clone())
> + .into()
> + };
> +
> + let selected = self.selected(props);
> +
> + let mut text = Row::new();
> + for (pos, item) in selected.iter().enumerate() {
> + if pos > 0 {
> + text.add_child(html! { {", "} });
> + }
> + text.add_child(match &props.render_value {
> + Some(render_value) => render_value.apply(item),
> + None => html! { item },
> + });
> + }
> + let text: Html = text.into();
> +
> + Dropdown::new(picker)
> + .with_std_props(&props.std_props)
> + .with_input_props(&props.input_props)
> + .value(
> + selected
> + .iter()
> + .map(|item| item.to_string())
> + .collect::<Vec<_>>()
> + .join(","),
> + )
> + .render_value(move |_: &AttrValue| text.clone())
> + .valid(self.result.is_ok())
> + .into()
> + }
> +}
next prev parent reply other threads:[~2026-11-02 14:12 UTC|newest]
Thread overview: 13+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-10-28 15:15 [PATCH proxmox-yew-widget-toolkit 0/5] add multi-select component Nicolas Frey
2026-10-28 15:15 ` [PATCH proxmox-yew-widget-toolkit 1/5] widget: data table: simplify lookup of keys near the cursor Nicolas Frey
2026-11-02 14:12 ` Dominik Csapak
2026-10-28 15:15 ` [PATCH proxmox-yew-widget-toolkit 2/5] widget: data table: only extend explicit select all to new rows Nicolas Frey
2026-11-02 14:12 ` Dominik Csapak
2026-10-28 15:15 ` [PATCH proxmox-yew-widget-toolkit 3/5] widget: grid picker: keep selected records hidden by the filter Nicolas Frey
2026-11-02 14:12 ` Dominik Csapak
2026-10-28 15:15 ` [PATCH proxmox-yew-widget-toolkit 4/5] widget: form: implement multi select field Nicolas Frey
2026-11-02 14:12 ` Dominik Csapak [this message]
2026-11-02 14:23 ` Dominik Csapak
2026-10-28 15:15 ` [PATCH proxmox-yew-widget-toolkit 5/5] widget: form: combobox: update doc to point to `MultiSelect` Nicolas Frey
2026-11-02 14:12 ` Dominik Csapak
2026-11-02 14:12 ` [PATCH proxmox-yew-widget-toolkit 0/5] add multi-select component Dominik Csapak
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=1d940f6b-355d-46ed-80ba-e8e14f58503b@proxmox.com \
--to=d.csapak@proxmox.com \
--cc=n.frey@proxmox.com \
--cc=yew-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox