streamlit-nightly 1.44.1.dev20250326__py3-none-any.whl → 1.44.1.dev20250327__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. streamlit/elements/lib/options_selector_utils.py +26 -5
  2. streamlit/elements/widgets/button_group.py +42 -11
  3. streamlit/elements/widgets/multiselect.py +166 -26
  4. streamlit/elements/widgets/selectbox.py +182 -38
  5. streamlit/proto/MultiSelect_pb2.py +4 -2
  6. streamlit/proto/MultiSelect_pb2.pyi +14 -2
  7. streamlit/proto/Selectbox_pb2.py +4 -2
  8. streamlit/proto/Selectbox_pb2.pyi +15 -2
  9. streamlit/static/index.html +1 -1
  10. streamlit/static/static/js/{FileDownload.esm.DyWffVOd.js → FileDownload.esm.jX-9l2Ep.js} +1 -1
  11. streamlit/static/static/js/{FileHelper.C5Rf1fu1.js → FileHelper.aCeQQwv9.js} +1 -1
  12. streamlit/static/static/js/{FormClearHelper.D2MZi-BE.js → FormClearHelper.CWUgHOqb.js} +1 -1
  13. streamlit/static/static/js/{Hooks.UTpr--3H.js → Hooks.z6bpnOa4.js} +1 -1
  14. streamlit/static/static/js/{InputInstructions.oXzay8Sc.js → InputInstructions.CxNXqmaa.js} +1 -1
  15. streamlit/static/static/js/{ProgressBar.Dkc6afbj.js → ProgressBar.DeJx_v03.js} +1 -1
  16. streamlit/static/static/js/{RenderInPortalIfExists.0vQEaJAa.js → RenderInPortalIfExists.BzVEnQEP.js} +1 -1
  17. streamlit/static/static/js/{Toolbar.BLonzKGv.js → Toolbar.Buaxb3gQ.js} +1 -1
  18. streamlit/static/static/js/{base-input.BVhyctGq.js → base-input.B02pchZb.js} +1 -1
  19. streamlit/static/static/js/{checkbox.DqunhWUH.js → checkbox.BNevNWhL.js} +1 -1
  20. streamlit/static/static/js/{createSuper.CpC2DZ-4.js → createSuper.HF1JI-bK.js} +1 -1
  21. streamlit/static/static/js/{data-grid-overlay-editor.BNc7oQAN.js → data-grid-overlay-editor.DHpEpsQ_.js} +1 -1
  22. streamlit/static/static/js/{downloader.BEmsLcSd.js → downloader.B32k91dq.js} +1 -1
  23. streamlit/static/static/js/{es6.Bka7yuWr.js → es6.j4L3xv_m.js} +2 -2
  24. streamlit/static/static/js/{iframeResizer.contentWindow.CseC9_0j.js → iframeResizer.contentWindow.DQOV--zq.js} +1 -1
  25. streamlit/static/static/js/{index.DcA9oLdi.js → index.1tdxODWC.js} +1 -1
  26. streamlit/static/static/js/{index.DDcRBpz_.js → index.B2-yUxP6.js} +1 -1
  27. streamlit/static/static/js/{index.NwBLa4vX.js → index.B28jf8c_.js} +13 -13
  28. streamlit/static/static/js/{index.DTByMWKb.js → index.BBHrAwbG.js} +1 -1
  29. streamlit/static/static/js/{index.DDfr2vz1.js → index.BDIF1v3E.js} +1 -1
  30. streamlit/static/static/js/{index.-uBfKDNW.js → index.BRDvEQpe.js} +1 -1
  31. streamlit/static/static/js/{index.CdRzA4dY.js → index.BT-PT2u0.js} +1 -1
  32. streamlit/static/static/js/{index.DPXDfP7O.js → index.BTG2J5Pk.js} +1 -1
  33. streamlit/static/static/js/{index.-GEjrTbP.js → index.BUz0sS-V.js} +1 -1
  34. streamlit/static/static/js/{index.sEBYkd7C.js → index.BeuGcxG8.js} +1 -1
  35. streamlit/static/static/js/{index.BgAs7EEH.js → index.BnYJb__c.js} +1 -1
  36. streamlit/static/static/js/{index.BGiS_myV.js → index.C-GJaT09.js} +12 -12
  37. streamlit/static/static/js/{index.CSzYGoqF.js → index.C1B9TyzK.js} +1 -1
  38. streamlit/static/static/js/{index.DfLVo1kK.js → index.CDMGlkYx.js} +1 -1
  39. streamlit/static/static/js/{index.DiZnms3l.js → index.CExICAHy.js} +1 -1
  40. streamlit/static/static/js/{index.Dg6pitBN.js → index.CKYXxi_d.js} +1 -1
  41. streamlit/static/static/js/index.CPMy5pwd.js +1 -0
  42. streamlit/static/static/js/{index.RS9NSuPn.js → index.CTgHTp02.js} +1 -1
  43. streamlit/static/static/js/{index.BPrkMaDs.js → index.CTwaWONb.js} +1 -1
  44. streamlit/static/static/js/{index.DH5gmTNJ.js → index.CcMFXZBL.js} +1 -1
  45. streamlit/static/static/js/index.ChvqDLgw.js +1 -0
  46. streamlit/static/static/js/{index.6ZLFnYFv.js → index.ClfebD_T.js} +1 -1
  47. streamlit/static/static/js/{index.CusXODwm.js → index.CpDe9l-f.js} +1 -1
  48. streamlit/static/static/js/{index.ByG1Ipsd.js → index.Cq_L2WtW.js} +1 -1
  49. streamlit/static/static/js/{index.D_RsV5T3.js → index.DBEif7dq.js} +2 -2
  50. streamlit/static/static/js/{index.DYmhwMwG.js → index.DNURUtUa.js} +2 -2
  51. streamlit/static/static/js/{index.DbB5iceE.js → index.DTDyF8nE.js} +1 -1
  52. streamlit/static/static/js/{index.C7zS3xAk.js → index.DaJw5fna.js} +1 -1
  53. streamlit/static/static/js/{index.-Ex9Sw0j.js → index.DbqewZ6W.js} +1 -1
  54. streamlit/static/static/js/{index.DvCWOxpn.js → index.DfvKnm4Q.js} +1 -1
  55. streamlit/static/static/js/{index.B9r4f8yP.js → index.DsRxnb2z.js} +1 -1
  56. streamlit/static/static/js/index.Nb8G9oM-.js +1 -0
  57. streamlit/static/static/js/{index.YDw6DAY-.js → index.R8Go3XlF.js} +1 -1
  58. streamlit/static/static/js/{index.BPw4HKmU.js → index.Uid-bSyh.js} +1 -1
  59. streamlit/static/static/js/{index.CHsi5M24.js → index.V3D0L00K.js} +1 -1
  60. streamlit/static/static/js/{index.K8WjTi1D.js → index.m0rRkw04.js} +1 -1
  61. streamlit/static/static/js/{index.BSsSumHQ.js → index.qkhdJyyt.js} +6 -6
  62. streamlit/static/static/js/{input.Cf4AxOS9.js → input.DogdK8Cg.js} +1 -1
  63. streamlit/static/static/js/{memory.x6zhEJ8E.js → memory.B_1d0kyG.js} +1 -1
  64. streamlit/static/static/js/{mergeWith.DL-_LuL_.js → mergeWith.9h0p6sC_.js} +1 -1
  65. streamlit/static/static/js/{number-overlay-editor.uQEd6gCw.js → number-overlay-editor.yRe6Yodu.js} +1 -1
  66. streamlit/static/static/js/{possibleConstructorReturn.6K8NTHlH.js → possibleConstructorReturn.C73_6grg.js} +1 -1
  67. streamlit/static/static/js/{sandbox.BY_N7kRf.js → sandbox.2u3nOS5d.js} +1 -1
  68. streamlit/static/static/js/{textarea.BoUKtqfY.js → textarea.DFCEFjUj.js} +1 -1
  69. streamlit/static/static/js/{timepicker.uTqYdkda.js → timepicker.GuNna1EN.js} +1 -1
  70. streamlit/static/static/js/{toConsumableArray.CA_5PwDK.js → toConsumableArray.DARzcvE5.js} +1 -1
  71. streamlit/static/static/js/{uniqueId.CmvPTKOg.js → uniqueId.fceb1ayN.js} +1 -1
  72. streamlit/static/static/js/{useBasicWidgetState.aMiqPmlk.js → useBasicWidgetState.D6255-xX.js} +1 -1
  73. streamlit/static/static/js/{useOnInputChange.DXi6gRKL.js → useOnInputChange.BjnOKne4.js} +1 -1
  74. streamlit/static/static/js/{withFullScreenWrapper._ccFC4BV.js → withFullScreenWrapper.B7h9p1kI.js} +1 -1
  75. streamlit/testing/v1/element_tree.py +8 -3
  76. {streamlit_nightly-1.44.1.dev20250326.dist-info → streamlit_nightly-1.44.1.dev20250327.dist-info}/METADATA +1 -1
  77. {streamlit_nightly-1.44.1.dev20250326.dist-info → streamlit_nightly-1.44.1.dev20250327.dist-info}/RECORD +81 -81
  78. streamlit/static/static/js/index.BgstN8el.js +0 -1
  79. streamlit/static/static/js/index.CRpHYHH-.js +0 -1
  80. streamlit/static/static/js/index.DByvN5ny.js +0 -1
  81. {streamlit_nightly-1.44.1.dev20250326.data → streamlit_nightly-1.44.1.dev20250327.data}/scripts/streamlit.cmd +0 -0
  82. {streamlit_nightly-1.44.1.dev20250326.dist-info → streamlit_nightly-1.44.1.dev20250327.dist-info}/WHEEL +0 -0
  83. {streamlit_nightly-1.44.1.dev20250326.dist-info → streamlit_nightly-1.44.1.dev20250327.dist-info}/entry_points.txt +0 -0
  84. {streamlit_nightly-1.44.1.dev20250326.dist-info → streamlit_nightly-1.44.1.dev20250327.dist-info}/top_level.txt +0 -0
@@ -15,7 +15,7 @@
15
15
  from __future__ import annotations
16
16
 
17
17
  from enum import Enum, EnumMeta
18
- from typing import TYPE_CHECKING, Any, Final, TypeVar, overload
18
+ from typing import TYPE_CHECKING, Any, Callable, Final, TypeVar, overload
19
19
 
20
20
  from streamlit import config, logger
21
21
  from streamlit.dataframe_util import OptionSequence, convert_anything_to_list
@@ -211,10 +211,10 @@ def maybe_coerce_enum(register_widget_result, options, opt_sequence):
211
211
  # (https://github.com/python/typing/issues/548)
212
212
  @overload
213
213
  def maybe_coerce_enum_sequence(
214
- register_widget_result: RegisterWidgetResult[list[T]],
214
+ register_widget_result: RegisterWidgetResult[list[T] | list[T | str]],
215
215
  options: OptionSequence[T],
216
216
  opt_sequence: Sequence[T],
217
- ) -> RegisterWidgetResult[list[T]]: ...
217
+ ) -> RegisterWidgetResult[list[T] | list[T | str]]: ...
218
218
 
219
219
 
220
220
  @overload
@@ -227,8 +227,8 @@ def maybe_coerce_enum_sequence(
227
227
 
228
228
  def maybe_coerce_enum_sequence(register_widget_result, options, opt_sequence):
229
229
  """Maybe Coerce a RegisterWidgetResult with a sequence of Enum members as value
230
- to RegisterWidgetResult[Sequence[option]] if option is an EnumType, otherwise just return
231
- the original RegisterWidgetResult.
230
+ to RegisterWidgetResult[Sequence[option]] if option is an EnumType, otherwise just
231
+ return the original RegisterWidgetResult.
232
232
  """
233
233
 
234
234
  # If not all widget values are Enums, return early
@@ -251,3 +251,24 @@ def maybe_coerce_enum_sequence(register_widget_result, options, opt_sequence):
251
251
  ),
252
252
  register_widget_result.value_changed,
253
253
  )
254
+
255
+
256
+ def create_mappings(
257
+ options: Sequence[T], format_func: Callable[[T], str] = str
258
+ ) -> tuple[list[str], dict[str, int]]:
259
+ """Iterates through the options and formats them using the format_func.
260
+
261
+ Returns a tuple of the formatted options and a mapping of the formatted options to
262
+ the original options.
263
+ """
264
+ formatted_option_to_option_mapping: dict[str, int] = {}
265
+ formatted_options: list[str] = []
266
+ for index, option in enumerate(options):
267
+ formatted_option = format_func(option)
268
+ formatted_options.append(formatted_option)
269
+ formatted_option_to_option_mapping[formatted_option] = index
270
+
271
+ return (
272
+ formatted_options,
273
+ formatted_option_to_option_mapping,
274
+ )
@@ -15,6 +15,7 @@
15
15
  from __future__ import annotations
16
16
 
17
17
  from collections.abc import Sequence
18
+ from dataclasses import dataclass, field
18
19
  from typing import (
19
20
  TYPE_CHECKING,
20
21
  Any,
@@ -31,6 +32,7 @@ from typing_extensions import TypeAlias
31
32
 
32
33
  from streamlit.elements.lib.form_utils import current_form_id
33
34
  from streamlit.elements.lib.options_selector_utils import (
35
+ check_and_convert_to_indices,
34
36
  convert_to_sequence_and_check_comparable,
35
37
  get_default_indices,
36
38
  )
@@ -46,7 +48,6 @@ from streamlit.elements.lib.utils import (
46
48
  save_for_app_testing,
47
49
  to_key,
48
50
  )
49
- from streamlit.elements.widgets.multiselect import MultiSelectSerde
50
51
  from streamlit.errors import StreamlitAPIException
51
52
  from streamlit.proto.ButtonGroup_pb2 import ButtonGroup as ButtonGroupProto
52
53
  from streamlit.runtime.metrics_util import gather_metrics
@@ -89,9 +90,39 @@ _SELECTED_STAR_ICON: Final = ":material/star_filled:"
89
90
  SelectionMode: TypeAlias = Literal["single", "multi"]
90
91
 
91
92
 
92
- class SingleSelectSerde(Generic[T]):
93
- """Uses the MultiSelectSerde under-the-hood, but accepts a single index value
94
- and deserializes to a single index value.
93
+ @dataclass
94
+ class _MultiSelectSerde(Generic[T]):
95
+ """Only meant to be used internally for the button_group element.
96
+
97
+ This serde is inspired by the MultiSelectSerde from multiselect.py. That serde has
98
+ been updated since then to support the accept_new_options parameter, which is not
99
+ required by the button_group element. If this changes again at some point,
100
+ the two elements can share the same serde again.
101
+ """
102
+
103
+ options: Sequence[T]
104
+ default_value: list[int] = field(default_factory=list)
105
+
106
+ def serialize(self, value: list[T]) -> list[int]:
107
+ indices = check_and_convert_to_indices(self.options, value)
108
+ return indices if indices is not None else []
109
+
110
+ def deserialize(
111
+ self,
112
+ ui_value: list[int] | None,
113
+ widget_id: str = "",
114
+ ) -> list[T]:
115
+ current_value: list[int] = (
116
+ ui_value if ui_value is not None else self.default_value
117
+ )
118
+ return [self.options[i] for i in current_value]
119
+
120
+
121
+ class _SingleSelectSerde(Generic[T]):
122
+ """Only meant to be used internally for the button_group element.
123
+
124
+ Uses the ButtonGroup's _MultiSelectSerde under-the-hood, but accepts a single
125
+ index value and deserializes to a single index value.
95
126
  This is because button_group can be single and multi select, but we use the same
96
127
  proto for both and, thus, map single values to a list of values and a receiving
97
128
  value wrapped in a list to a single value.
@@ -106,7 +137,7 @@ class SingleSelectSerde(Generic[T]):
106
137
  default_value: list[int] | None = None,
107
138
  ) -> None:
108
139
  # see docstring about why we use MultiSelectSerde here
109
- self.multiselect_serde: MultiSelectSerde[T] = MultiSelectSerde(
140
+ self.multiselect_serde: _MultiSelectSerde[T] = _MultiSelectSerde(
110
141
  option_indices, default_value if default_value is not None else []
111
142
  )
112
143
 
@@ -123,7 +154,7 @@ class SingleSelectSerde(Generic[T]):
123
154
  return deserialized[0]
124
155
 
125
156
 
126
- class SingleOrMultiSelectSerde(Generic[T]):
157
+ class ButtonGroupSerde(Generic[T]):
127
158
  """A serde that can handle both single and multi select options.
128
159
 
129
160
  It uses the same proto to wire the data, so that we can send and receive
@@ -142,10 +173,10 @@ class SingleOrMultiSelectSerde(Generic[T]):
142
173
  self.options = options
143
174
  self.default_values = default_values
144
175
  self.type = type
145
- self.serde: SingleSelectSerde[T] | MultiSelectSerde[T] = (
146
- SingleSelectSerde(options, default_value=default_values)
176
+ self.serde: _SingleSelectSerde[T] | _MultiSelectSerde[T] = (
177
+ _SingleSelectSerde(options, default_value=default_values)
147
178
  if type == "single"
148
- else MultiSelectSerde(options, default_values)
179
+ else _MultiSelectSerde(options, default_values)
149
180
  )
150
181
 
151
182
  def serialize(self, value: T | list[T] | None) -> list[int]:
@@ -362,7 +393,7 @@ class ButtonGroupMixin:
362
393
  f"The argument passed was '{options}'."
363
394
  )
364
395
  transformed_options, options_indices = get_mapped_options(options)
365
- serde = SingleSelectSerde[int](options_indices)
396
+ serde = _SingleSelectSerde[int](options_indices)
366
397
 
367
398
  selection_visualization = ButtonGroupProto.SelectionVisualization.ONLY_SELECTED
368
399
  if options == "stars":
@@ -857,7 +888,7 @@ class ButtonGroupMixin:
857
888
  indexable_options = convert_to_sequence_and_check_comparable(options)
858
889
  default_values = get_default_indices(indexable_options, default)
859
890
 
860
- serde: SingleOrMultiSelectSerde[V] = SingleOrMultiSelectSerde[V](
891
+ serde: ButtonGroupSerde[V] = ButtonGroupSerde[V](
861
892
  indexable_options, default_values, selection_mode
862
893
  )
863
894
 
@@ -14,15 +14,15 @@
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
- from dataclasses import dataclass, field
17
+ from collections.abc import Sequence
18
18
  from textwrap import dedent
19
- from typing import TYPE_CHECKING, Any, Callable, Generic, cast
19
+ from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, cast, overload
20
20
 
21
- from streamlit.dataframe_util import OptionSequence
21
+ from streamlit.dataframe_util import OptionSequence, convert_anything_to_list
22
22
  from streamlit.elements.lib.form_utils import current_form_id
23
23
  from streamlit.elements.lib.options_selector_utils import (
24
- check_and_convert_to_indices,
25
24
  convert_to_sequence_and_check_comparable,
25
+ create_mappings,
26
26
  get_default_indices,
27
27
  maybe_coerce_enum_sequence,
28
28
  )
@@ -62,24 +62,76 @@ if TYPE_CHECKING:
62
62
  )
63
63
 
64
64
 
65
- @dataclass
66
65
  class MultiSelectSerde(Generic[T]):
67
66
  options: Sequence[T]
68
- default_value: list[int] = field(default_factory=list)
67
+ formatted_options: list[str]
68
+ formatted_option_to_option_index: dict[str, int]
69
+ default_options_indices: list[int]
69
70
 
70
- def serialize(self, value: list[T]) -> list[int]:
71
- indices = check_and_convert_to_indices(self.options, value)
72
- return indices if indices is not None else []
71
+ def __init__(
72
+ self,
73
+ options: Sequence[T],
74
+ *,
75
+ formatted_options: list[str],
76
+ formatted_option_to_option_index: dict[str, int],
77
+ default_options_indices: list[int] | None = None,
78
+ ):
79
+ """Initialize the MultiSelectSerde.
80
+
81
+ We do not store an option_to_formatted_option mapping because the generic
82
+ options might not be hashable, which would raise a RuntimeError. So we do
83
+ two lookups: option -> index -> formatted_option[index].
84
+
85
+
86
+ Parameters
87
+ ----------
88
+ options : Sequence[T]
89
+ The sequence of selectable options.
90
+ formatted_options : list[str]
91
+ The string representations of each option. The formatted_options correspond
92
+ to the options sequence by index.
93
+ formatted_option_to_option_index : dict[str, int]
94
+ A mapping from formatted option strings to their corresponding indices in
95
+ the options sequence.
96
+ default_option_index : int or None, optional
97
+ The index of the default option to use when no selection is made.
98
+ If None, no default option is selected.
99
+ """
100
+
101
+ self.options = options
102
+ self.formatted_options = formatted_options
103
+ self.formatted_option_to_option_index = formatted_option_to_option_index
104
+ self.default_options_indices = default_options_indices or []
105
+
106
+ def serialize(self, value: list[T | str] | list[T]) -> list[str]:
107
+ converted_value = convert_anything_to_list(value)
108
+ values: list[str] = []
109
+ for v in converted_value:
110
+ try:
111
+ option_index = self.options.index(v)
112
+ values.append(self.formatted_options[option_index])
113
+ except ValueError:
114
+ # at this point we know that v is a string, otherwise
115
+ # it would have been found in the options
116
+ values.append(cast("str", v))
117
+ return values
73
118
 
74
119
  def deserialize(
75
120
  self,
76
- ui_value: list[int] | None,
121
+ ui_value: list[str] | None,
77
122
  widget_id: str = "",
78
- ) -> list[T]:
79
- current_value: list[int] = (
80
- ui_value if ui_value is not None else self.default_value
81
- )
82
- return [self.options[i] for i in current_value]
123
+ ) -> list[T | str] | list[T]:
124
+ if ui_value is None:
125
+ return [self.options[i] for i in self.default_options_indices]
126
+
127
+ values: list[T | str] = []
128
+ for v in ui_value:
129
+ try:
130
+ option_index = self.formatted_options.index(v)
131
+ values.append(self.options[option_index])
132
+ except ValueError:
133
+ values.append(v)
134
+ return values
83
135
 
84
136
 
85
137
  def _get_default_count(default: Sequence[Any] | Any | None) -> int:
@@ -104,13 +156,73 @@ def _check_max_selections(
104
156
 
105
157
 
106
158
  class MultiSelectMixin:
159
+ @overload
160
+ def multiselect(
161
+ self,
162
+ label: str,
163
+ options: OptionSequence[T],
164
+ default: Any | None = None,
165
+ format_func: Callable[[Any], str] = str,
166
+ key: Key | None = None,
167
+ help: str | None = None,
168
+ on_change: WidgetCallback | None = None,
169
+ args: WidgetArgs | None = None,
170
+ kwargs: WidgetKwargs | None = None,
171
+ *, # keyword-only arguments:
172
+ max_selections: int | None = None,
173
+ placeholder: str | None = None,
174
+ disabled: bool = False,
175
+ label_visibility: LabelVisibility = "visible",
176
+ accept_new_options: Literal[False] = False,
177
+ ) -> list[T]: ...
178
+
179
+ @overload
180
+ def multiselect(
181
+ self,
182
+ label: str,
183
+ options: OptionSequence[T],
184
+ default: Any | None = None,
185
+ format_func: Callable[[Any], str] = str,
186
+ key: Key | None = None,
187
+ help: str | None = None,
188
+ on_change: WidgetCallback | None = None,
189
+ args: WidgetArgs | None = None,
190
+ kwargs: WidgetKwargs | None = None,
191
+ *, # keyword-only arguments:
192
+ max_selections: int | None = None,
193
+ placeholder: str | None = None,
194
+ disabled: bool = False,
195
+ label_visibility: LabelVisibility = "visible",
196
+ accept_new_options: Literal[True] = True,
197
+ ) -> list[T | str]: ...
198
+
199
+ @overload
200
+ def multiselect(
201
+ self,
202
+ label: str,
203
+ options: OptionSequence[T],
204
+ default: Any | None = None,
205
+ format_func: Callable[[Any], str] = str,
206
+ key: Key | None = None,
207
+ help: str | None = None,
208
+ on_change: WidgetCallback | None = None,
209
+ args: WidgetArgs | None = None,
210
+ kwargs: WidgetKwargs | None = None,
211
+ *, # keyword-only arguments:
212
+ max_selections: int | None = None,
213
+ placeholder: str | None = None,
214
+ disabled: bool = False,
215
+ label_visibility: LabelVisibility = "visible",
216
+ accept_new_options: bool = False,
217
+ ) -> list[T] | list[T | str]: ...
218
+
107
219
  @gather_metrics("multiselect")
108
220
  def multiselect(
109
221
  self,
110
222
  label: str,
111
223
  options: OptionSequence[T],
112
224
  default: Any | None = None,
113
- format_func: Callable[[Any], Any] = str,
225
+ format_func: Callable[[Any], str] = str,
114
226
  key: Key | None = None,
115
227
  help: str | None = None,
116
228
  on_change: WidgetCallback | None = None,
@@ -118,10 +230,11 @@ class MultiSelectMixin:
118
230
  kwargs: WidgetKwargs | None = None,
119
231
  *, # keyword-only arguments:
120
232
  max_selections: int | None = None,
121
- placeholder: str = "Choose an option",
233
+ placeholder: str | None = None,
122
234
  disabled: bool = False,
123
235
  label_visibility: LabelVisibility = "visible",
124
- ) -> list[T]:
236
+ accept_new_options: Literal[False, True] | bool = False,
237
+ ) -> list[T] | list[T | str]:
125
238
  r"""Display a multiselect widget.
126
239
  The multiselect widget starts as empty.
127
240
 
@@ -190,9 +303,10 @@ class MultiSelectMixin:
190
303
  max_selections: int
191
304
  The max selections that can be selected at a time.
192
305
 
193
- placeholder: str
306
+ placeholder: str | None
194
307
  A string to display when no options are selected.
195
- Defaults to "Choose an option."
308
+ Defaults to "Choose an option." or, if
309
+ ``accept_new_options`` is set to ``True``, to "Choose or add an option".
196
310
 
197
311
  disabled: bool
198
312
  An optional boolean that disables the multiselect widget if set
@@ -204,6 +318,11 @@ class MultiSelectMixin:
204
318
  label, which can help keep the widget alligned with other widgets.
205
319
  If this is ``"collapsed"``, Streamlit displays no label or spacer.
206
320
 
321
+ accept_new_options: bool
322
+ If ``True``, the user can enter new options that don't exist in the
323
+ original options. The ``max_options`` argument is still enforced.
324
+ The default is ``False``.
325
+
207
326
  Returns
208
327
  -------
209
328
  list
@@ -241,6 +360,7 @@ class MultiSelectMixin:
241
360
  placeholder=placeholder,
242
361
  disabled=disabled,
243
362
  label_visibility=label_visibility,
363
+ accept_new_options=accept_new_options,
244
364
  ctx=ctx,
245
365
  )
246
366
 
@@ -257,11 +377,12 @@ class MultiSelectMixin:
257
377
  kwargs: WidgetKwargs | None = None,
258
378
  *, # keyword-only arguments:
259
379
  max_selections: int | None = None,
260
- placeholder: str = "Choose an option",
380
+ placeholder: str | None = None,
261
381
  disabled: bool = False,
262
382
  label_visibility: LabelVisibility = "visible",
383
+ accept_new_options: bool = False,
263
384
  ctx: ScriptRunContext | None = None,
264
- ) -> list[T]:
385
+ ) -> list[T] | list[T | str]:
265
386
  key = to_key(key)
266
387
 
267
388
  widget_name = "multiselect"
@@ -274,9 +395,19 @@ class MultiSelectMixin:
274
395
  maybe_raise_label_warnings(label, label_visibility)
275
396
 
276
397
  indexable_options = convert_to_sequence_and_check_comparable(options)
277
- formatted_options = [format_func(option) for option in indexable_options]
398
+ formatted_options, formatted_option_to_option_index = create_mappings(
399
+ indexable_options, format_func
400
+ )
401
+
278
402
  default_values = get_default_indices(indexable_options, default)
279
403
 
404
+ if placeholder is None:
405
+ placeholder = (
406
+ "Choose an option"
407
+ if not accept_new_options
408
+ else "Choose or add an option"
409
+ )
410
+
280
411
  form_id = current_form_id(self.dg)
281
412
  element_id = compute_and_register_element_id(
282
413
  widget_name,
@@ -288,6 +419,7 @@ class MultiSelectMixin:
288
419
  help=help,
289
420
  max_selections=max_selections,
290
421
  placeholder=placeholder,
422
+ accept_new_options=accept_new_options,
291
423
  )
292
424
 
293
425
  proto = MultiSelectProto()
@@ -304,8 +436,15 @@ class MultiSelectMixin:
304
436
  proto.options[:] = formatted_options
305
437
  if help is not None:
306
438
  proto.help = dedent(help)
439
+ proto.accept_new_options = accept_new_options
440
+
441
+ serde = MultiSelectSerde(
442
+ indexable_options,
443
+ formatted_options=formatted_options,
444
+ formatted_option_to_option_index=formatted_option_to_option_index,
445
+ default_options_indices=default_values,
446
+ )
307
447
 
308
- serde = MultiSelectSerde(indexable_options, default_values)
309
448
  widget_state = register_widget(
310
449
  proto.id,
311
450
  on_change_handler=on_change,
@@ -314,16 +453,17 @@ class MultiSelectMixin:
314
453
  deserializer=serde.deserialize,
315
454
  serializer=serde.serialize,
316
455
  ctx=ctx,
317
- value_type="int_array_value",
456
+ value_type="string_array_value",
318
457
  )
319
458
 
320
459
  _check_max_selections(widget_state.value, max_selections)
460
+
321
461
  widget_state = maybe_coerce_enum_sequence(
322
462
  widget_state, options, indexable_options
323
463
  )
324
464
 
325
465
  if widget_state.value_changed:
326
- proto.value[:] = serde.serialize(widget_state.value)
466
+ proto.raw_values[:] = serde.serialize(widget_state.value)
327
467
  proto.set_value = True
328
468
 
329
469
  if ctx: