diff --git a/src/myfasthtml/core/bindings.py b/src/myfasthtml/core/bindings.py
index 361cfbe..acb7503 100644
--- a/src/myfasthtml/core/bindings.py
+++ b/src/myfasthtml/core/bindings.py
@@ -7,7 +7,7 @@ from fasthtml.fastapp import fast_app
from myutils.observable import make_observable, bind, collect_return_values, unbind
from myfasthtml.core.constants import Routes, ROUTE_ROOT
-from myfasthtml.core.utils import get_default_attr, get_default_ft_attr, is_checkbox, is_radio
+from myfasthtml.core.utils import get_default_attr, get_default_ft_attr, is_checkbox, is_radio, is_select
bindings_app, bindings_rt = fast_app()
logger = logging.getLogger("Bindings")
@@ -16,11 +16,13 @@ logger = logging.getLogger("Bindings")
class UpdateMode(Enum):
ValueChange = "ValueChange"
AttributePresence = "AttributePresence"
+ SelectValueChange = "SelectValueChange"
class DetectionMode(Enum):
ValueChange = "ValueChange"
AttributePresence = "AttributePresence"
+ SelectValueChange = "SelectValueChange"
class AttrChangedDetection:
@@ -51,6 +53,19 @@ class ValueChangedDetection(AttrChangedDetection):
return False, None
+class SelectValueChangedDetection(AttrChangedDetection):
+ """
+ Search for the attribute that is modified.
+ """
+
+ def matches(self, values):
+ for key, value in values.items():
+ if key == self.attr:
+ return True, value
+
+ return True, []
+
+
class AttrPresentDetection(AttrChangedDetection):
"""
Search if the attribute is present in the data object.
@@ -70,19 +85,25 @@ class ValueChangeFtUpdate(FtUpdate):
# simple mode, just update the text or the attribute
new_to_use = converter.convert(new) if converter else new
if ft_attr is None:
- if ft.tag == "select":
- for child in [c for c in ft.children if c.tag == "option"]:
- if child.attrs.get("value", None) == new_to_use:
- child.attrs["selected"] = "true"
- else:
- child.attrs.pop("selected", None)
- else:
- ft.children = (new_to_use,)
+ ft.children = (new_to_use,)
else:
ft.attrs[ft_attr] = new_to_use
return ft
+class SelectValueChangeFtUpdate(FtUpdate):
+ def update(self, ft, ft_name, ft_attr, old, new, converter):
+ # simple mode, just update the text or the attribute
+ new_to_use = converter.convert(new) if converter else new
+ new_to_use = [new_to_use] if not isinstance(new_to_use, list) else new_to_use
+ for child in [c for c in ft.children if c.tag == "option"]:
+ if child.attrs.get("value", None) in new_to_use:
+ child.attrs["selected"] = "true"
+ else:
+ child.attrs.pop("selected", None)
+ return ft
+
+
class AttributePresenceFtUpdate(FtUpdate):
def update(self, ft, ft_name, ft_attr, old, new, converter):
# attribute presence mode, toggle the attribute (add or remove it)
@@ -198,6 +219,10 @@ class Binding:
default_data_converter = self.data_converter or RadioConverter(ft.attrs["value"])
default_detection_mode = DetectionMode.ValueChange
default_update_mode = UpdateMode.AttributePresence
+ elif is_select(ft):
+ default_data_converter = self.data_converter
+ default_detection_mode = DetectionMode.SelectValueChange
+ default_update_mode = UpdateMode.SelectValueChange
else:
default_data_converter = self.data_converter
default_detection_mode = DetectionMode.ValueChange
@@ -340,12 +365,18 @@ class Binding:
elif mode == DetectionMode.AttributePresence:
return AttrPresentDetection(self.ft_name)
+ elif mode == DetectionMode.SelectValueChange:
+ return SelectValueChangedDetection(self.ft_name)
+
elif mode == UpdateMode.ValueChange:
return ValueChangeFtUpdate()
elif mode == UpdateMode.AttributePresence:
return AttributePresenceFtUpdate()
+ elif mode == UpdateMode.SelectValueChange:
+ return SelectValueChangeFtUpdate()
+
else:
raise ValueError(f"Invalid detection mode: {mode}")
diff --git a/src/myfasthtml/core/utils.py b/src/myfasthtml/core/utils.py
index b48fe14..a58f04b 100644
--- a/src/myfasthtml/core/utils.py
+++ b/src/myfasthtml/core/utils.py
@@ -125,6 +125,15 @@ def is_radio(elt):
return False
+def is_select(elt):
+ if isinstance(elt, (FT, MyFT)):
+ return elt.tag == "select"
+ elif isinstance(elt, Tag):
+ return elt.name == "select"
+ else:
+ return False
+
+
def quoted_str(s):
if s is None:
return "None"
diff --git a/src/myfasthtml/examples/binding_select_multiple.py b/src/myfasthtml/examples/binding_select_multiple.py
new file mode 100644
index 0000000..2d733e7
--- /dev/null
+++ b/src/myfasthtml/examples/binding_select_multiple.py
@@ -0,0 +1,47 @@
+import logging
+from dataclasses import dataclass
+from typing import Any
+
+from fasthtml import serve
+from fasthtml.components import *
+
+from myfasthtml.controls.helpers import mk
+from myfasthtml.core.bindings import Binding
+from myfasthtml.core.utils import debug_routes
+from myfasthtml.myfastapp import create_app
+
+logging.basicConfig(
+ level=logging.DEBUG, # Set logging level to DEBUG
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', # Log format
+ datefmt='%Y-%m-%d %H:%M:%S', # Timestamp format
+)
+
+app, rt = create_app(protect_routes=False)
+
+
+@dataclass
+class Data:
+ value: Any = "Hello World"
+
+
+data = Data()
+
+
+@rt("/")
+def get():
+ select_elt = Select(
+ Option("Option 1", value="option1"),
+ Option("Option 2", value="option2"),
+ Option("Option 3", value="option3"),
+ name="select_name",
+ multiple=True
+ )
+ label_elt = Label()
+ mk.manage_binding(select_elt, Binding(data), init_binding=False)
+ mk.manage_binding(label_elt, Binding(data))
+ return select_elt, label_elt
+
+
+if __name__ == "__main__":
+ debug_routes(app)
+ serve(port=5002)
diff --git a/src/myfasthtml/test/testclient.py b/src/myfasthtml/test/testclient.py
index fdded5b..c2fee2f 100644
--- a/src/myfasthtml/test/testclient.py
+++ b/src/myfasthtml/test/testclient.py
@@ -325,7 +325,7 @@ class TestableElement:
# Extract all options
options = []
- selected_value = None
+ selected_value = []
for option in select_field.find_all('option'):
option_value = option.get('value', option.get_text(strip=True))
@@ -338,16 +338,20 @@ class TestableElement:
# Track selected option
if option.has_attr('selected'):
- selected_value = option_value
+ selected_value.append(option_value)
# Store options list
self.select_fields[name] = options
# Store selected value (or first option if none selected)
- if selected_value is not None:
+ is_multiple = select_field.has_attr('multiple')
+ if is_multiple:
self.fields[name] = selected_value
- elif options:
- self.fields[name] = options[0]['value']
+ else:
+ if len(selected_value) > 0:
+ self.fields[name] = selected_value[-1]
+ elif options:
+ self.fields[name] = options[0]['value']
# Process textarea fields
for textarea_field in self.element.find_all('textarea'):
@@ -783,7 +787,7 @@ class TestableSelect(TestableControl):
current = [current] if current else []
if value not in current:
current.append(value)
- self.fields[self.name] = current
+ self.fields[self.name] = current[0] if len(current) == 1 else current # it's not a list when only one is selected
else:
# For single select, just set the value
self.fields[self.name] = value
@@ -835,7 +839,7 @@ class TestableSelect(TestableControl):
if value in current:
current.remove(value)
- self.fields[self.name] = current
+ self.fields[self.name] = current[0] if len(current) == 1 else current
return self._send_value()
return None
diff --git a/tests/controls/test_manage_binding.py b/tests/controls/test_manage_binding.py
index 64341f8..37acc29 100644
--- a/tests/controls/test_manage_binding.py
+++ b/tests/controls/test_manage_binding.py
@@ -304,7 +304,109 @@ class TestBindingSelect:
class TestBindingSelectMultiple:
"""Tests for binding Select components with multiple selection."""
- def test_i_can_bind_select_multiple(self, user, rt):
+ def test_i_can_bind_select_multiple(self):
+ data = Data("")
+ select_elt = Select(
+ Option("Option 1", value="option1"),
+ Option("Option 2", value="option2"),
+ Option("Option 3", value="option3"),
+ name="select_name",
+ multiple=True
+ )
+
+ binding = Binding(data)
+ updated = mk.manage_binding(select_elt, binding)
+
+ expected = Select(
+ AttributeForbidden("hx_swap_oob"),
+ Option("Option 1", value="option1"),
+ Option("Option 2", value="option2"),
+ Option("Option 3", value="option3"),
+ name="select_name",
+ id=AnyValue(),
+ hx_post=f"{ROUTE_ROOT}{Routes.Bindings}",
+ )
+ assert matches(updated, expected)
+
+ def test_i_can_update_one_selection(self):
+ data = Data("")
+ select_elt = Select(
+ Option("Option 1", value="option1"),
+ Option("Option 2", value="option2"),
+ Option("Option 3", value="option3"),
+ name="select_name",
+ multiple=True
+ )
+
+ binding = Binding(data)
+ mk.manage_binding(select_elt, binding)
+
+ res = binding.update({"select_name": "option2"})
+
+ expected = Select(
+ Option("Option 1", value="option1"),
+ Option("Option 2", value="option2", selected="true"),
+ Option("Option 3", value="option3"),
+ name="select_name",
+ id=AnyValue(),
+ hx_post=f"{ROUTE_ROOT}{Routes.Bindings}",
+ hx_swap_oob="true"
+ )
+ assert matches(res, [expected])
+
+ def test_i_can_update_multiple_selections(self):
+ data = Data("")
+ select_elt = Select(
+ Option("Option 1", value="option1"),
+ Option("Option 2", value="option2"),
+ Option("Option 3", value="option3"),
+ name="select_name",
+ multiple=True
+ )
+
+ binding = Binding(data)
+ mk.manage_binding(select_elt, binding)
+
+ res = binding.update({"select_name": ["option2", "option3"]})
+
+ expected = Select(
+ Option(AttributeForbidden("selected"), "Option 1", value="option1"),
+ Option("Option 2", value="option2", selected="true"),
+ Option("Option 3", value="option3", selected="true"),
+ name="select_name",
+ id=AnyValue(),
+ hx_post=f"{ROUTE_ROOT}{Routes.Bindings}",
+ hx_swap_oob="true"
+ )
+ assert matches(res, [expected])
+
+ def test_i_can_update_unselect(self):
+ data = Data(["option1", "option2", "option3"])
+ select_elt = Select(
+ Option("Option 1", value="option1", selected="true"),
+ Option("Option 2", value="option2", selected="true"),
+ Option("Option 3", value="option3", selected="true"),
+ name="select_name",
+ multiple=True
+ )
+
+ binding = Binding(data)
+ mk.manage_binding(select_elt, binding)
+
+ res = binding.update({})
+
+ expected = Select(
+ Option(AttributeForbidden("selected"), "Option 1", value="option1"),
+ Option(AttributeForbidden("selected"), "Option 2", value="option2"),
+ Option(AttributeForbidden("selected"), "Option 3", value="option3"),
+ name="select_name",
+ id=AnyValue(),
+ hx_post=f"{ROUTE_ROOT}{Routes.Bindings}",
+ hx_swap_oob="true"
+ )
+ assert matches(res, [expected])
+
+ def test_i_can_bind_select_multiple_with_label(self, user, rt):
"""
Multiple select should bind with list data.
Selecting multiple options should update the label.
@@ -360,7 +462,7 @@ class TestBindingSelectMultiple:
testable_select = user.find_element("select")
testable_select.deselect("option1")
- user.should_see("['option2']")
+ user.should_see("option2")
class TestBindingRange:
diff --git a/tests/testclient/test_testable_select_multiple.py b/tests/testclient/test_testable_select_multiple.py
new file mode 100644
index 0000000..4b5b313
--- /dev/null
+++ b/tests/testclient/test_testable_select_multiple.py
@@ -0,0 +1,107 @@
+import pytest
+from fasthtml.fastapp import fast_app
+
+from myfasthtml.test.testclient import TestableSelect, MyTestClient
+
+
+@pytest.fixture
+def test_app():
+ test_app, rt = fast_app(default_hdrs=False)
+ return test_app
+
+
+@pytest.fixture
+def rt(test_app):
+ return test_app.route
+
+
+@pytest.fixture
+def test_client(test_app):
+ return MyTestClient(test_app)
+
+
+def test_i_can_read_select(test_client):
+ html = '''
+ '''
+ select_elt = TestableSelect(test_client, html)
+ assert select_elt.name == "select_name"
+ assert select_elt.value == []
+ assert select_elt.options == [{'text': 'Option 1', 'value': 'option1'},
+ {'text': 'Option 2', 'value': 'option2'},
+ {'text': 'Option 3', 'value': 'option3'}]
+ assert select_elt.select_fields == {'select_name': [{'text': 'Option 1', 'value': 'option1'},
+ {'text': 'Option 2', 'value': 'option2'},
+ {'text': 'Option 3', 'value': 'option3'}]}
+ assert select_elt.is_multiple is True
+
+
+def test_i_can_read_select_with_multiple_selected_values(test_client):
+ html = '''
+ '''
+ select_elt = TestableSelect(test_client, html)
+ assert select_elt.name == "select_name"
+ assert select_elt.value == ["option1", "option3"]
+ assert select_elt.is_multiple is True
+
+
+def test_i_can_select_option(test_client):
+ html = '''
+ '''
+ select_elt = TestableSelect(test_client, html)
+ select_elt.select("option2")
+ assert select_elt.value == "option2"
+
+
+def test_i_can_select_multiple_options(test_client):
+ html = '''
+ '''
+ select_elt = TestableSelect(test_client, html)
+ select_elt.select("option2")
+ select_elt.select("option3")
+ assert select_elt.value == ["option2", "option3"]
+
+
+def test_i_can_select_by_text(test_client):
+ html = '''
+ '''
+ select_elt = TestableSelect(test_client, html)
+ select_elt.select_by_text("Option 3")
+ assert select_elt.value == "option3"
+
+
+def test_i_can_deselect(test_client):
+ html = '''
+ '''
+ select_elt = TestableSelect(test_client, html)
+ select_elt.deselect("option3")
+ assert select_elt.value == ["option1", "option2"]
+
+ select_elt.deselect("option2")
+ assert select_elt.value == "option1"
+
+ select_elt.deselect("option1")
+ assert select_elt.value == []