From: "Nicolas Frey" <n.frey@proxmox.com>
To: "Dominik Csapak" <d.csapak@proxmox.com>, <yew-devel@lists.proxmox.com>
Subject: Re: [PATCH proxmox-yew-widget-toolkit 4/5] widget: form: implement multi select field
Date: Thu, 05 Nov 2026 11:17:15 +0100 [thread overview]
Message-ID: <DMN77BBTGLOJ.33LLVJ8YGR58A@proxmox.com> (raw)
In-Reply-To: <7ed54cd3-6dcb-4ecb-bbb3-86302ade8df5@proxmox.com>
On Mon Nov 2, 2026 at 3:23 PM CET, Dominik Csapak wrote:
> another thing i forgot to write in my previous message:
>
> Is it intended for the field to grow when many items are selected?
>
> I can imagine that his breaks some ui layout depending on where
> it's located...
I mention it in the cover letter, so yes it was intended. though the
more I think about it, it would be great to have this configurable.
meaning if it's not expected to break layout, it can grow - otherwise
have it cut off with ellipses at the end?
>
> 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-05 10:17 UTC|newest]
Thread overview: 22+ 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-11-06 9:35 ` applied: " Dietmar Maurer
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-11-05 10:17 ` Nicolas Frey
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-11-05 10:17 ` Nicolas Frey
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
2026-11-05 10:17 ` Nicolas Frey
2026-11-02 14:23 ` Dominik Csapak
2026-11-05 10:17 ` Nicolas Frey [this message]
2026-11-05 10:36 ` 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-05 10:17 ` Nicolas Frey
2026-11-02 14:12 ` [PATCH proxmox-yew-widget-toolkit 0/5] add multi-select component Dominik Csapak
2026-11-06 7:19 ` Dietmar Maurer
2026-11-06 8:41 ` Dietmar Maurer
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=DMN77BBTGLOJ.33LLVJ8YGR58A@proxmox.com \
--to=n.frey@proxmox.com \
--cc=d.csapak@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