Black Lives Matter. Please consider donating to Black Girls Code today.
Dash HoloViews is now available! Check out the docs.

Limit number of values in multi-select dropdown without disabling

I have a multi-select dropdown with many options. I want to limit the maximum number of values a user can select to 3 but I don’t want to disable the dropdown at 3 - I want the user to be able to remove values and add new ones once they reach 3.

I tried writing a callback with the dropdown values as both Input and Output. I was hoping that I could check if len(dropdown_values) > 3 then I would return dropdown_values[0:3] for the values. But it seems the same component cannot be both an Input and an Output.

Any advice on how I can achieve this behavior would be greatly appreciated.

I came up with the following solution. Use a Store component to trigger removing the 4th added value.

@app.callback([Output('comparison-dropdown', 'value')],
                     [Input('store-prev-comparisons', 'data')])
def trim_dropdown(prev_comparisons):
  if prev_comparisons is None: # on page load
    raise dash.exceptions.PreventUpdate
  else:
    return prev_comparisons,

@app.callback([Output('store-prev-comparisons', 'data')],
                     [Input('comparison-dropdown', 'value')])
def select_comparison(comparisons):
  if len(comparisons) == 4:
    return comparisons[0:3], # changes store-prev-comparisons which triggers above callback
  else: # when <= 3 don't modify store-prev-comparisons and therefore don't trigger above
    return dash.no_update

This solution creates problems with “Circular Dependencies”, how did you get it to work without having this problem?

That’s a good question, sorry I forgot to include that part after I discovered it.

Yes, there is circularity in the above: comparison-dropdown.value [select_comparison()] > store-prev-comparisons.data [trim_dropdown()] > comparison-dropdown.value [select_comparison()].

The way to stop it is to observe that after trim_dropdown() finishes you have comparison-dropdown.value == store-prev-comparisons.data. So all you have to do is pass store-prev-comparisons.data as a State() to select_comparison(), check for this equality, and PreventUpdate if it holds.

@app.callback([Output('store-prev-comparisons', 'data')],
                     [Input('comparison-dropdown', 'value')],
                     [State('store-prev-comparisons', 'data')])
def select_comparison(comparisons, prev_comparisons):
  if len(comparisons) == 4:
    # changes store-prev-comparisons which triggers above callback
    return comparisons[0:3], 
  elif comparisons == prev_comparisons:
     # this only happens if we just trimmed so don't do anything to break circularity
     raise dash.exception.PreventUpdate 
  else: 
    # when <= 3 don't modify store-prev-comparisons and therefore don't trigger above
    return dash.no_update

By the way, if you have other callbacks that respond to comparison-dropdown.value to do something with the values you can add the following to keep them from responding when the limit is reached and the above trimming executes.

if len(comparisons) == MAX_COMPARISONS + 1 or comparisons == prev_comparisons:
  raise dash.exceptions.PreventUpdate
1 Like

Greetings, I’ve stumbled upon this thread and realized how badly I need something like this in my dashboard.

However I’m struggling trying to implement your syntax into mine.

Here is my dropdown callback, hopefully someone is able to help me solve the same issue you had.

@app.callback(
    dash.dependencies.Output("input-warning", "children"),
    [dash.dependencies.Input("dynamic-dropdown", "search_value")],
    [dash.dependencies.State("dynamic-dropdown", "value")],
)
def update_multi_options(search_value, value):
    conn.rollback()
    if search_value is None:
        return [[], OPTIONS]
    elif len(search_value) >= 4:
        children = html.P("limit reached",id='input-warning')
        return [children,[option for option in OPTIONS if option['value'] in search_value]]  
    else:
    # Make sure that the set values are in the option list, else they will disappear
    # from the shown select list, but still part of the `value`.
        return [
            o for o in OPTIONS if search_value.upper() in o["label"].upper() or o["value"] in (value or [])
        ]