Repository: streamlit/streamlit

Issue:
Limit the number of selection in multi select
#### Multi Select doesn't have limit for the number of selections
#### Please add option to set a limit to the number of selection allowed in a multi select cell





Discussion:
Any updates? 
I also want this, please
Any perspective on this? It would be very interesting to have it
Any update ? I have like 70 options for selection and the user should only be able to choose 10 out of them. 
I have done this by going through this approch:

 ```
st.write("Select atmost 2 locations")
location = st.multiselect("Where do you live?", ("New York", "San Francisco", "Chicago", "Seattle"))
button = st.button("Print Locations",disabled=False)

if button :
    if len(location) <= 2:
        st.write(location)
    else:
        st.warning("You have to select only 2 locations")
```

hope it will help!!!!!

Resolving patch:
diff --git a/e2e/scripts/st_multiselect.py b/e2e/scripts/st_multiselect.py
--- a/e2e/scripts/st_multiselect.py
+++ b/e2e/scripts/st_multiselect.py
@@ -16,19 +16,25 @@
 
 import streamlit as st
 
+
+def set_multiselect_9_to_have_bad_state():
+    if "multiselect 9" in st.session_state:
+        st.session_state["multiselect 9"] = ["male", "female"]
+
+
 options = ("male", "female")
 
 i1 = st.multiselect("multiselect 1", options)
-st.text("value 1: %s" % i1)
+st.text(f"value 1: {i1}")
 
 i2 = st.multiselect("multiselect 2", options, format_func=lambda x: x.capitalize())
-st.text("value 2: %s" % i2)
+st.text(f"value 2: {i2}")
 
 i3: List[Any] = st.multiselect("multiselect 3", [])
-st.text("value 3: %s" % i3)
+st.text(f"value 3: {i3}")
 
 i4 = st.multiselect("multiselect 4", ["coffee", "tea", "water"], ["tea", "water"])
-st.text("value 4: %s" % i4)
+st.text(f"value 4: {i4}")
 
 i5 = st.multiselect(
     "multiselect 5",
@@ -39,22 +45,36 @@
         )
     ),
 )
-st.text("value 5: %s" % i5)
+st.text(f"value 5: {i5}")
 
 i6 = st.multiselect("multiselect 6", options, disabled=True)
-st.text("value 6: %s" % i6)
+st.text(f"value 6: {i6}")
 
 i7 = st.multiselect("Hidden label", options, label_visibility="hidden")
-st.text("value 7: %s" % i7)
+st.text(f"value 7: {i7}")
 
 i8 = st.multiselect("Collapsed label", options, label_visibility="collapsed")
-st.text("value 8: %s" % i8)
+st.text(f"value 8: {i8}")
+
+set_multiselect_9 = st.checkbox(
+    "set_multiselect_9", on_change=set_multiselect_9_to_have_bad_state
+)
+
+i9 = st.multiselect("multiselect 9", options, max_selections=1, key="multiselect 9")
+st.text(f"value 9: {i9}")
+
+with st.form("my_max_selections_ms_in_form"):
+    i10 = st.multiselect(
+        "multiselect 10", options, max_selections=1, key="multiselect 10"
+    )
+    st.text(f"value 10: {i10}")
+    submitted = st.form_submit_button("Submit")
 
 if st._is_running_with_streamlit:
 
     def on_change():
         st.session_state.multiselect_changed = True
 
-    st.multiselect("multiselect 9", options, key="multiselect9", on_change=on_change)
-    st.text("value 9: %s" % st.session_state.multiselect9)
+    st.multiselect("multiselect 11", options, key="multiselect11", on_change=on_change)
+    st.text(f"value 11: {st.session_state.multiselect11}")
     st.text(f"multiselect changed: {'multiselect_changed' in st.session_state}")
diff --git a/lib/streamlit/elements/multiselect.py b/lib/streamlit/elements/multiselect.py
--- a/lib/streamlit/elements/multiselect.py
+++ b/lib/streamlit/elements/multiselect.py
@@ -19,7 +19,6 @@
     Callable,
     cast,
     Generic,
-    Iterable,
     Optional,
     overload,
     List,
@@ -40,6 +39,7 @@
     T,
     LabelVisibility,
     maybe_raise_label_warnings,
+    is_iterable,
 )
 
 from streamlit.runtime.state import (
@@ -70,13 +70,13 @@ def _check_and_convert_to_indices(  # type: ignore[misc]
 
 @overload
 def _check_and_convert_to_indices(
-    opt: Sequence[Any], default_values: Union[Iterable[Any], Any]
+    opt: Sequence[Any], default_values: Union[Sequence[Any], Any]
 ) -> List[int]:
     ...
 
 
 def _check_and_convert_to_indices(
-    opt: Sequence[Any], default_values: Union[Iterable[Any], Any, None]
+    opt: Sequence[Any], default_values: Union[Sequence[Any], Any, None]
 ) -> Optional[List[int]]:
     """Perform validation checks and return indices based on the default values."""
     if default_values is None and None not in opt:
@@ -89,7 +89,7 @@ def _check_and_convert_to_indices(
         if is_type(default_values, "numpy.ndarray") or is_type(
             default_values, "pandas.core.series.Series"
         ):
-            default_values = list(cast(Iterable[Any], default_values))
+            default_values = list(cast(Sequence[Any], default_values))
         elif not default_values or default_values in opt:
             default_values = [default_values]
         else:
@@ -104,6 +104,26 @@ def _check_and_convert_to_indices(
     return [opt.index(value) for value in default_values]
 
 
+def _get_default_count(default: Union[Sequence[Any], Any, None]) -> int:
+    if default is None:
+        return 0
+    if not is_iterable(default):
+        return 1
+    return len(cast(Sequence[Any], default))
+
+
+def _get_over_max_options_message(current_selections: int, max_selections: int):
+    curr_selections_noun = "option" if current_selections == 1 else "options"
+    max_selections_noun = "option" if max_selections == 1 else "options"
+    return f"""
+Multiselect has {current_selections} {curr_selections_noun} selected but `max_selections`
+is set to {max_selections}. This happened because you either gave too many options to `default`
+or you manipulated the widget's state through `st.session_state`. Note that
+the latter can happen before the line indicated in the traceback.
+Please select at most {max_selections} {max_selections_noun}.
+"""
+
+
 @dataclass
 class MultiSelectSerde(Generic[T]):
     options: Sequence[T]
@@ -139,6 +159,7 @@ def multiselect(
         *,  # keyword-only arguments:
         disabled: bool = False,
         label_visibility: LabelVisibility = "visible",
+        max_selections: Optional[int] = None,
     ) -> List[T]:
         """Display a multiselect widget.
         The multiselect widget starts as empty.
@@ -182,6 +203,9 @@ def multiselect(
             is still empty space for it above the widget (equivalent to label="").
             If "collapsed", both the label and the space are removed. Default is
             "visible". This argument can only be supplied by keyword.
+        max_selections : int
+            The max selections that can be selected at a time.
+            This argument can only be supplied by keyword.
 
         Returns
         -------
@@ -216,13 +240,14 @@ def multiselect(
             disabled=disabled,
             label_visibility=label_visibility,
             ctx=ctx,
+            max_selections=max_selections,
         )
 
     def _multiselect(
         self,
         label: str,
         options: OptionSequence[T],
-        default: Union[Iterable[Any], Any, None] = None,
+        default: Union[Sequence[Any], Any, None] = None,
         format_func: Callable[[Any], Any] = str,
         key: Optional[Key] = None,
         help: Optional[str] = None,
@@ -233,6 +258,7 @@ def _multiselect(
         disabled: bool = False,
         label_visibility: LabelVisibility = "visible",
         ctx: Optional[ScriptRunContext] = None,
+        max_selections: Optional[int] = None,
     ) -> List[T]:
         key = to_key(key)
         check_callback_rules(self.dg, on_change)
@@ -248,6 +274,7 @@ def _multiselect(
         multiselect_proto.default[:] = default_value
         multiselect_proto.options[:] = [str(format_func(option)) for option in opt]
         multiselect_proto.form_id = current_form_id(self.dg)
+        multiselect_proto.max_selections = max_selections or 0
         if help is not None:
             multiselect_proto.help = dedent(help)
 
@@ -264,6 +291,12 @@ def _multiselect(
             serializer=serde.serialize,
             ctx=ctx,
         )
+        default_count = _get_default_count(widget_state.value)
+        if max_selections and default_count > max_selections:
+            raise StreamlitAPIException(
+                _get_over_max_options_message(default_count, max_selections)
+            )
+
         # This needs to be done after register_widget because we don't want
         # the following proto fields to affect a widget's ID.
         multiselect_proto.disabled = disabled


Test patch:
diff --git a/frontend/src/components/widgets/Multiselect/Multiselect.test.tsx b/frontend/src/components/widgets/Multiselect/Multiselect.test.tsx
--- a/frontend/src/components/widgets/Multiselect/Multiselect.test.tsx
+++ b/frontend/src/components/widgets/Multiselect/Multiselect.test.tsx
@@ -288,4 +288,109 @@ describe("Multiselect widget", () => {
       }
     )
   })
+  describe("properly invalidates going over max selections", () => {
+    it("has correct noResultsMsg when maxSelections is not passed", () => {
+      const props = getProps(
+        MultiSelectProto.create({
+          id: "1",
+          label: "Label",
+          default: [0],
+          options: ["a", "b", "c"],
+        })
+      )
+      const wrapper = mount(<Multiselect {...props} />)
+
+      expect(wrapper.find(UISelect).props()).toHaveProperty(
+        "noResultsMsg",
+        "No results"
+      )
+    })
+
+    it("has correct noResultsMsg when maxSelections is passed", () => {
+      const props = getProps(
+        MultiSelectProto.create({
+          id: "1",
+          label: "Label",
+          default: [0, 1],
+          options: ["a", "b", "c"],
+          maxSelections: 2,
+        })
+      )
+      const wrapper = mount(<Multiselect {...props} />)
+
+      expect(wrapper.find(UISelect).props()).toHaveProperty(
+        "noResultsMsg",
+        "You can only select up to 2 options. Remove an option first."
+      )
+    })
+
+    it("has correct noResultsMsg when maxSelections === 1", () => {
+      const props = getProps(
+        MultiSelectProto.create({
+          id: "1",
+          label: "Label",
+          default: [0, 1],
+          options: ["a", "b", "c"],
+          maxSelections: 1,
+        })
+      )
+      const wrapper = mount(<Multiselect {...props} />)
+
+      expect(wrapper.find(UISelect).prop("noResultsMsg")).toBe(
+        "You can only select up to 1 option. Remove an option first."
+      )
+    })
+
+    it("does not allow for more selection when an option is picked", () => {
+      const props = getProps(
+        MultiSelectProto.create({
+          id: "1",
+          label: "Label",
+          default: [0],
+          options: ["a", "b", "c"],
+          maxSelections: 1,
+        })
+      )
+      const wrapper = mount(<Multiselect {...props} />)
+
+      // @ts-ignore
+      wrapper.find(UISelect).prop("onChange")({
+        type: "select",
+        option: {
+          value: 1,
+        },
+      })
+      wrapper.update()
+
+      expect(wrapper.find(UISelect).prop("value")).toStrictEqual([
+        { label: "a", value: "0" },
+      ])
+    })
+
+    it("does allow an option to be removed when we are at max selections", () => {
+      const props = getProps(
+        MultiSelectProto.create({
+          id: "1",
+          label: "Label",
+          default: [0, 1],
+          options: ["a", "b", "c"],
+          maxSelections: 2,
+        })
+      )
+      const wrapper = mount(<Multiselect {...props} />)
+
+      // @ts-ignore
+      wrapper.find(UISelect).prop("onChange")({
+        type: "remove",
+        option: {
+          value: 1,
+        },
+      })
+      wrapper.update()
+
+      expect(wrapper.find(UISelect).prop("value")).toStrictEqual([
+        { label: "a", value: "0" },
+      ])
+    })
+  })
 })
diff --git a/lib/tests/streamlit/elements/multiselect_test.py b/lib/tests/streamlit/elements/multiselect_test.py
--- a/lib/tests/streamlit/elements/multiselect_test.py
+++ b/lib/tests/streamlit/elements/multiselect_test.py
@@ -20,7 +20,11 @@
 from parameterized import parameterized
 
 import streamlit as st
-from streamlit.errors import StreamlitAPIException
+from streamlit import StreamlitAPIException
+from streamlit.elements.multiselect import (
+    _get_default_count,
+    _get_over_max_options_message,
+)
 from streamlit.proto.LabelVisibilityMessage_pb2 import LabelVisibilityMessage
 from tests import testutil
 
@@ -269,3 +273,94 @@ def test_label_visibility_wrong_value(self):
                 "Unsupported label_visibility option 'wrong_value'. Valid values are "
                 "'visible', 'hidden' or 'collapsed'.",
             )
+
+    def test_max_selections(self):
+        st.multiselect("the label", ("m", "f"), max_selections=2)
+
+        c = self.get_delta_from_queue().new_element.multiselect
+        self.assertEqual(c.max_selections, 2)
+
+    def test_over_max_selections_initialization(self):
+        with self.assertRaises(StreamlitAPIException) as e:
+            st.multiselect(
+                "the label", ["a", "b", "c", "d"], ["a", "b", "c"], max_selections=2
+            )
+        self.assertEqual(
+            str(e.exception),
+            """
+Multiselect has 3 options selected but `max_selections`
+is set to 2. This happened because you either gave too many options to `default`
+or you manipulated the widget's state through `st.session_state`. Note that
+the latter can happen before the line indicated in the traceback.
+Please select at most 2 options.
+""",
+        )
+
+    @parameterized.expand(
+        [
+            (["a", "b", "c"], 3),
+            (["a"], 1),
+            ([], 0),
+            ("a", 1),
+            (None, 0),
+            (("a", "b", "c"), 3),
+        ]
+    )
+    def test_get_default_count(self, default, expected_count):
+        self.assertEqual(_get_default_count(default), expected_count)
+
+    @parameterized.expand(
+        [
+            (
+                1,
+                1,
+                f"""
+Multiselect has 1 option selected but `max_selections`
+is set to 1. This happened because you either gave too many options to `default`
+or you manipulated the widget's state through `st.session_state`. Note that
+the latter can happen before the line indicated in the traceback.
+Please select at most 1 option.
+""",
+            ),
+            (
+                1,
+                0,
+                f"""
+Multiselect has 1 option selected but `max_selections`
+is set to 0. This happened because you either gave too many options to `default`
+or you manipulated the widget's state through `st.session_state`. Note that
+the latter can happen before the line indicated in the traceback.
+Please select at most 0 options.
+""",
+            ),
+            (
+                2,
+                1,
+                f"""
+Multiselect has 2 options selected but `max_selections`
+is set to 1. This happened because you either gave too many options to `default`
+or you manipulated the widget's state through `st.session_state`. Note that
+the latter can happen before the line indicated in the traceback.
+Please select at most 1 option.
+""",
+            ),
+            (
+                3,
+                2,
+                f"""
+Multiselect has 3 options selected but `max_selections`
+is set to 2. This happened because you either gave too many options to `default`
+or you manipulated the widget's state through `st.session_state`. Note that
+the latter can happen before the line indicated in the traceback.
+Please select at most 2 options.
+""",
+            ),
+        ]
+    )
+    def test_get_over_max_options_message(
+        self, current_selections, max_selections, expected_msg
+    ):
+        self.assertEqual(
+            _get_over_max_options_message(current_selections, max_selections),
+            expected_msg,
+        )