Repository: marimo-team/marimo

Issue:
Dropdown should support non-strings
### Description

`mo.ui.dropdown(options=[1,2,3])` will crash with:

```
Bad Data
options.0: Expected string, received number
```

This can be worked around with dict comprehension.

### Suggested solution

It would be preferable if these `ui` elements just worked with options as any list, not just a list of strings. Same with `multiselect`. This would avoid doing `{str(x): x for x in [1,2,3]}` workarounds. 

### Alternative

_No response_

### Additional context

![Image](https://github.com/user-attachments/assets/50c97196-ae0f-4dca-91b6-1f35f834e318)

Discussion:

Solution patch:
diff --git a/frontend/src/plugins/impl/anywidget/AnyWidgetPlugin.tsx b/frontend/src/plugins/impl/anywidget/AnyWidgetPlugin.tsx
index a1b858f7..bdf155f1 100644
--- a/frontend/src/plugins/impl/anywidget/AnyWidgetPlugin.tsx
+++ b/frontend/src/plugins/impl/anywidget/AnyWidgetPlugin.tsx
@@ -128,12 +128,16 @@ const AnyWidgetSlot = (props: Props) => {
     return <ErrorBanner error={error} />;
   }
 
+  // Find the closest parent element with an attribute of `random-id`
+  const randomId = props.host.closest("[random-id]")?.getAttribute("random-id");
+  const key = randomId ?? jsUrl;
+
   return (
     <LoadedSlot
-      // Use the jsUrl as a key to force a re-render when the jsUrl changes
+      // Use the a key to force a re-render when the randomId (or jsUrl) changes
       // Plugins may be stateful and we cannot make assumptions that we won't be
       // so it is safer to just re-render.
-      key={jsUrl}
+      key={key}
       {...props}
       widget={module.default}
       value={valueWithBuffer}
diff --git a/marimo/_plugins/ui/_impl/input.py b/marimo/_plugins/ui/_impl/input.py
index 31d1d7b8..f094cc3b 100644
--- a/marimo/_plugins/ui/_impl/input.py
+++ b/marimo/_plugins/ui/_impl/input.py
@@ -879,6 +879,13 @@ class code_editor(UIElement[str, str]):
         return value
 
 
+def _to_option_name(option: Any) -> str:
+    if isinstance(option, str):
+        return option
+    else:
+        return repr(option)
+
+
 @mddoc
 class dropdown(UIElement[list[str], Any]):
     """A dropdown selector.
@@ -917,8 +924,10 @@ class dropdown(UIElement[list[str], Any]):
         selected_key (str, optional): The selected option's key, or None if no selection.
 
     Args:
-        options (Sequence[str] | dict[str, Any]): Sequence of text options, or dict
+        options (Sequence[Any] | dict[str, Any]): Sequence of options, or dict
             mapping option name to option value.
+            If the options are not strings, they will be converted to strings
+            when displayed in the dropdown.
         value (str, optional): Default option name. Defaults to None.
         allow_select_none (bool, optional): Whether to include special option ("--")
             for a None value; when None, defaults to True when value is None.
@@ -935,11 +944,12 @@ class dropdown(UIElement[list[str], Any]):
     _MAX_OPTIONS: Final[int] = 1000
     _name: Final[str] = "marimo-dropdown"
     _selected_key: Optional[str] = None
+    _RESERVED_OPTION: Final[str] = "--"
 
     def __init__(
         self,
-        options: Sequence[str] | dict[str, Any],
-        value: Optional[str] = None,
+        options: Sequence[Any] | dict[str, Any],
+        value: Optional[Any] = None,
         allow_select_none: Optional[bool] = None,
         searchable: bool = False,
         *,
@@ -959,12 +969,15 @@ class dropdown(UIElement[list[str], Any]):
             )
 
         if not isinstance(options, dict):
-            options = {option: option for option in options}
+            options = {_to_option_name(option): option for option in options}
 
-        if "--" in options:
+            if value is not None and not isinstance(value, str):
+                value = _to_option_name(value)
+
+        if self._RESERVED_OPTION in options:
             raise ValueError(
-                "The option name '--' is reserved by marimo"
-                "; please use another name."
+                f"The option name '{self._RESERVED_OPTION}' "
+                "is reserved by marimo; please use another name."
             )
 
         self.options = options
@@ -1000,8 +1013,15 @@ class dropdown(UIElement[list[str], Any]):
 
     def _convert_value(self, value: list[str]) -> Any:
         if value:
-            assert len(value) == 1
+            assert len(value) == 1, "Dropdowns only support a single value"
             self._selected_key = value[0]
+            if self._selected_key not in self.options:
+                raise ValueError(
+                    f"The option name '{self._selected_key}' "
+                    "is not a valid option. "
+                    "Please use one of the following options: "
+                    f"{list(self.options.keys())}"
+                )
             return self.options[value[0]]
         else:
             self._selected_key = None
@@ -1034,8 +1054,10 @@ class multiselect(UIElement[list[str], list[object]]):
         options (dict): A dict mapping option name to option value.
 
     Args:
-        options (Sequence[str] | dict[str, Any]): Sequence of text options, or dict
+        options (Sequence[Any] | dict[str, Any]): Sequence of options, or dict
             mapping option name to option value.
+            If the options are not strings, they will be converted to strings
+            when displayed in the dropdown.
         value (Sequence[str], optional): A list of initially selected options.
             Defaults to None.
         label (str, optional): Markdown label for the element. Defaults to "".
@@ -1052,8 +1074,8 @@ class multiselect(UIElement[list[str], list[object]]):
 
     def __init__(
         self,
-        options: Sequence[str] | dict[str, Any],
-        value: Optional[Sequence[str]] = None,
+        options: Sequence[Any] | dict[str, Any],
+        value: Optional[Sequence[Any]] = None,
         *,
         label: str = "",
         on_change: Optional[Callable[[list[object]], None]] = None,
@@ -1072,7 +1094,10 @@ class multiselect(UIElement[list[str], list[object]]):
             )
 
         if not isinstance(options, dict):
-            options = {option: option for option in options}
+            options = {_to_option_name(option): option for option in options}
+
+            if value is not None and not isinstance(value, str):
+                value = [_to_option_name(v) for v in value]
 
         self.options = options
         initial_value = list(value) if value is not None else []


Test patch:
diff --git a/marimo/_smoke_tests/dropdowns.py b/marimo/_smoke_tests/dropdowns.py
index b44dd2d9..70d3bf6b 100644
--- a/marimo/_smoke_tests/dropdowns.py
+++ b/marimo/_smoke_tests/dropdowns.py
@@ -1,6 +1,6 @@
 import marimo
 
-__generated_with = "0.11.0"
+__generated_with = "0.11.23"
 app = marimo.App(width="medium")
 
 
@@ -68,6 +68,20 @@ def _(mo):
     return
 
 
+@app.cell
+def _(mo):
+    # Mixed types
+    mixed_types = [1, "string", 3.14, True, None, (1, 2)]
+    dropdown_mixed_types = mo.ui.dropdown(options=mixed_types)
+    return dropdown_mixed_types, mixed_types
+
+
+@app.cell
+def _(dropdown_mixed_types, mo):
+    mo.hstack([dropdown_mixed_types, dropdown_mixed_types.value])
+    return
+
+
 @app.cell(hide_code=True)
 def _(mo):
     mo.md(r"""# multiselect""")
@@ -75,7 +89,7 @@ def _(mo):
 
 
 @app.cell
-def _(mo):
+def _(mixed_types, mo):
     # multiselect
     multiselect1 = mo.ui.multiselect(
         options=["Option 1", "Option 2", "Option 3"], value=["Option 1"]
@@ -95,7 +109,15 @@ def _(mo):
     multiselect4 = mo.ui.multiselect(
         options=["Cat", "Dog", "Bird"], value=None, max_selections=2
     )
-    return multiselect1, multiselect2, multiselect3, multiselect4
+
+    multiselect_mixed_types = mo.ui.multiselect(options=mixed_types)
+    return (
+        multiselect1,
+        multiselect2,
+        multiselect3,
+        multiselect4,
+        multiselect_mixed_types,
+    )
 
 
 @app.cell
@@ -109,5 +131,11 @@ def _(multiselect1, multiselect2, multiselect3, multiselect4):
     return
 
 
+@app.cell
+def _(mo, multiselect_mixed_types):
+    mo.hstack([multiselect_mixed_types, multiselect_mixed_types.value])
+    return
+
+
 if __name__ == "__main__":
     app.run()
diff --git a/tests/_plugins/ui/_impl/test_input.py b/tests/_plugins/ui/_impl/test_input.py
index a02d9ef2..493e830f 100644
--- a/tests/_plugins/ui/_impl/test_input.py
+++ b/tests/_plugins/ui/_impl/test_input.py
@@ -1,6 +1,7 @@
 # Copyright 2024 Marimo. All rights reserved.
 from __future__ import annotations
 
+from dataclasses import dataclass
 from typing import Any
 
 import pytest
@@ -337,6 +338,60 @@ def test_dropdown() -> None:
     assert dd.value == 2
 
 
+@dataclass
+class SomeObject:
+    a: int
+
+
+def test_dropdown_with_non_string_options() -> None:
+    # Integer options
+    dd = ui.dropdown(options=[1, 2, 3], value=1)
+    assert dd.options == {"1": 1, "2": 2, "3": 3}
+    assert dd.value == 1
+
+    dd._update(["2"])
+    assert dd.value == 2
+
+    # Float options
+    dd = ui.dropdown(options=[1.0, 2.0, 3.0], value=1.0)
+    assert dd.options == {"1.0": 1.0, "2.0": 2.0, "3.0": 3.0}
+    assert dd.value == 1.0
+
+    dd._update(["2.0"])
+    assert dd.value == 2.0
+
+    # Boolean options
+    dd = ui.dropdown(options=[True, False], value=True)
+    assert dd.options == {"True": True, "False": False}
+    assert dd.value is True
+
+    dd._update(["False"])
+    assert dd.value is False
+
+    # Rich objects
+    dd = ui.dropdown(
+        options=[SomeObject(a=1), SomeObject(a=2), SomeObject(a=3)],
+        value=SomeObject(a=1),
+    )
+    assert dd.options == {
+        "SomeObject(a=1)": SomeObject(a=1),
+        "SomeObject(a=2)": SomeObject(a=2),
+        "SomeObject(a=3)": SomeObject(a=3),
+    }
+    assert dd.value == SomeObject(a=1)
+
+    dd._update(["SomeObject(a=2)"])
+    assert dd.value == SomeObject(a=2)
+
+    # Mixed types
+    dd = ui.dropdown(
+        options=[1, "2", SomeObject(a=3)],
+        value="2",
+    )
+    assert dd.options == {"1": 1, "2": "2", "SomeObject(a=3)": SomeObject(a=3)}
+    assert dd.value == "2"
+
+
 def test_dropdown_too_many_options() -> None:
     with pytest.raises(ValueError) as e:
         ui.dropdown(options={str(i): i for i in range(2000)})
@@ -396,6 +451,68 @@ def test_multiselect() -> None:
         ms = ui.multiselect(options=options_list, max_selections=-10)
 
 
+def test_multiselect_non_string_options() -> None:
+    # Integer options
+    options_list = [1, 2, 3]
+    ms = ui.multiselect(options=options_list)
+    assert ms.options == {"1": 1, "2": 2, "3": 3}
+    assert ms.value == []
+
+    ms._update(["1"])
+    assert ms.value == [1]
+
+    ms._update(["1", "2"])
+    assert ms.value == [1, 2]
+
+    # Float options
+    options_list = [1.0, 2.0, 3.0]
+    ms = ui.multiselect(options=options_list, value=[1.0])
+    assert ms.options == {"1.0": 1.0, "2.0": 2.0, "3.0": 3.0}
+    assert ms.value == [1.0]
+
+    # Boolean options
+    options_list = [True, False]
+    ms = ui.multiselect(options=options_list, value=[True])
+    assert ms.options == {"True": True, "False": False}
+    assert ms.value == [True]
+
+    # Rich objects
+    options_list = [SomeObject(a=1), SomeObject(a=2), SomeObject(a=3)]
+    ms = ui.multiselect(options=options_list, value=[SomeObject(a=1)])
+    assert ms.options == {
+        "SomeObject(a=1)": SomeObject(a=1),
+        "SomeObject(a=2)": SomeObject(a=2),
+        "SomeObject(a=3)": SomeObject(a=3),
+    }
+    assert ms.value == [SomeObject(a=1)]
+
+    ms._update(["SomeObject(a=2)"])
+    assert ms.value == [SomeObject(a=2)]
+
+    # Mixed types
+    options_list = [1, "2", (3, 4)]
+    ms = ui.multiselect(options=options_list, value=["1"])
+    assert ms.options == {"1": 1, "2": "2", "(3, 4)": (3, 4)}
+    assert ms.value == [1]
+
+    ms._update(["1", "(3, 4)"])
+    assert ms.value == [1, (3, 4)]
+
+
+@pytest.mark.skipif(not HAS_PANDAS, reason="pandas not installed")
+def test_multiselect_from_series_non_string() -> None:
+    import pandas as pd
+
+    df = pd.DataFrame({"A": [1, 2, 3]})
+    ms = ui.multiselect.from_series(df["A"], value=["2"])
+    assert ms.options == {
+        "1": 1,
+        "2": 2,
+        "3": 3,
+    }
+    assert ms.value == [2]
+
+
 def test_multiselect_too_many_options() -> None:
     with pytest.raises(ValueError) as e:
         ui.multiselect(options={str(i): i for i in range(200000)})