public inbox for yew-devel@lists.proxmox.com
 help / color / mirror / Atom feed
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()
>> +    }
>> +}




  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
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal