diff --git a/src/myfasthtml/core/bindings.py b/src/myfasthtml/core/bindings.py
index acb7503..dca0be0 100644
--- a/src/myfasthtml/core/bindings.py
+++ b/src/myfasthtml/core/bindings.py
@@ -3,11 +3,12 @@ import uuid
from enum import Enum
from typing import Optional, Any
+from fasthtml.components import Option
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, is_select
+from myfasthtml.core.utils import get_default_attr, get_default_ft_attr, is_checkbox, is_radio, is_select, is_datalist
bindings_app, bindings_rt = fast_app()
logger = logging.getLogger("Bindings")
@@ -17,6 +18,7 @@ class UpdateMode(Enum):
ValueChange = "ValueChange"
AttributePresence = "AttributePresence"
SelectValueChange = "SelectValueChange"
+ DatalistListChange = "DatalistListChange"
class DetectionMode(Enum):
@@ -104,6 +106,13 @@ class SelectValueChangeFtUpdate(FtUpdate):
return ft
+class DatalistListChangeFtUpdate(FtUpdate):
+ def update(self, ft, ft_name, ft_attr, old, new, converter):
+ new_to_use = converter.convert(new) if converter else new
+ ft.children = tuple([Option(value=v) for v in new_to_use])
+ 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)
@@ -134,6 +143,20 @@ class BooleanConverter(DataConverter):
return False
+class ListConverter(DataConverter):
+ def convert(self, data):
+ if data is None:
+ return []
+
+ if isinstance(data, str):
+ return data.split("\n")
+
+ if isinstance(data, (list, set, tuple)):
+ return data
+
+ return [data]
+
+
class RadioConverter(DataConverter):
def __init__(self, radio_value):
self.radio_value = radio_value
@@ -223,6 +246,10 @@ class Binding:
default_data_converter = self.data_converter
default_detection_mode = DetectionMode.SelectValueChange
default_update_mode = UpdateMode.SelectValueChange
+ elif is_datalist(ft):
+ default_data_converter = self.data_converter or ListConverter()
+ default_detection_mode = DetectionMode.SelectValueChange
+ default_update_mode = UpdateMode.DatalistListChange
else:
default_data_converter = self.data_converter
default_detection_mode = DetectionMode.ValueChange
@@ -377,6 +404,9 @@ class Binding:
elif mode == UpdateMode.SelectValueChange:
return SelectValueChangeFtUpdate()
+ elif mode == UpdateMode.DatalistListChange:
+ return DatalistListChangeFtUpdate()
+
else:
raise ValueError(f"Invalid detection mode: {mode}")
diff --git a/src/myfasthtml/core/utils.py b/src/myfasthtml/core/utils.py
index a58f04b..bae9259 100644
--- a/src/myfasthtml/core/utils.py
+++ b/src/myfasthtml/core/utils.py
@@ -134,6 +134,15 @@ def is_select(elt):
return False
+def is_datalist(elt):
+ if isinstance(elt, (FT, MyFT)):
+ return elt.tag == "datalist"
+ elif isinstance(elt, Tag):
+ return elt.name == "datalist"
+ else:
+ return False
+
+
def quoted_str(s):
if s is None:
return "None"
diff --git a/src/myfasthtml/examples/binding_datalist.py b/src/myfasthtml/examples/binding_datalist.py
index e930ff0..00a24e6 100644
--- a/src/myfasthtml/examples/binding_datalist.py
+++ b/src/myfasthtml/examples/binding_datalist.py
@@ -7,6 +7,7 @@ from fasthtml.components import *
from myfasthtml.controls.helpers import mk
from myfasthtml.core.bindings import Binding
+from myfasthtml.core.commands import Command
from myfasthtml.core.utils import debug_routes
from myfasthtml.myfastapp import create_app
@@ -24,15 +25,22 @@ class Data:
value: Any = "Hello World"
-data = Data()
+def add_suggestion():
+ nb = len(data.value)
+ data.value.append(f"suggestion{nb}")
+
+
+def remove_suggestion():
+ if len(data.value) > 0:
+ data.value.pop()
+
+
+data = Data(["suggestion1", "suggestion2", "suggestion3"])
@rt("/")
def get():
datalist = Datalist(
- Option(value="suggestion1"),
- Option(value="suggestion2"),
- Option(value="suggestion3"),
id="suggestions"
)
input_elt = Input(name="input_name", list="suggestions")
@@ -41,7 +49,16 @@ def get():
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
- return input_elt, datalist, label_elt
+ add_button = mk.button("Add", command=Command("Add", "Add a suggestion", add_suggestion))
+ remove_button = mk.button("Remove", command=Command("Remove", "Remove a suggestion", remove_suggestion))
+
+ return Div(
+ add_button,
+ remove_button,
+ input_elt,
+ datalist,
+ label_elt
+ )
if __name__ == "__main__":
diff --git a/src/myfasthtml/examples/binding_range.py b/src/myfasthtml/examples/binding_range.py
new file mode 100644
index 0000000..89bdf2d
--- /dev/null
+++ b/src/myfasthtml/examples/binding_range.py
@@ -0,0 +1,40 @@
+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
+
+app, rt = create_app(protect_routes=False)
+
+
+@dataclass
+class Data:
+ value: Any = "Hello World"
+
+
+data = Data(50)
+
+
+@rt("/")
+def get():
+ range_elt = Input(
+ type="range",
+ name="range_name",
+ min="0",
+ max="100",
+ value="50"
+ )
+ label_elt = Label()
+ mk.manage_binding(range_elt, Binding(data))
+ mk.manage_binding(label_elt, Binding(data))
+ return range_elt, label_elt
+
+
+if __name__ == "__main__":
+ debug_routes(app)
+ serve(port=5002)
diff --git a/tests/controls/test_manage_binding.py b/tests/controls/test_manage_binding.py
index e1df766..808ab79 100644
--- a/tests/controls/test_manage_binding.py
+++ b/tests/controls/test_manage_binding.py
@@ -18,7 +18,7 @@ from typing import Any
import pytest
from fasthtml.components import (
- Input, Label, Textarea, Select, Option, Button, Datalist
+ Input, Label, Textarea, Select, Option, Datalist
)
from fasthtml.fastapp import fast_app
@@ -468,7 +468,59 @@ class TestBindingSelectMultiple:
class TestBindingRange:
"""Tests for binding Range (slider) components."""
- def test_i_can_bind_range(self, user, rt):
+ def test_i_can_bind_range(self):
+ data = Data(50)
+ range_elt = Input(
+ type="range",
+ name="range_name",
+ min="0",
+ max="100",
+ value="50"
+ )
+
+ binding = Binding(data)
+ updated = mk.manage_binding(range_elt, binding)
+
+ expected = Input(
+ AttributeForbidden("hx_swap_oob"),
+ type="range",
+ name="range_name",
+ min="0",
+ max="100",
+ value=50,
+ hx_post=f"{ROUTE_ROOT}{Routes.Bindings}",
+ id=AnyValue(),
+ )
+ assert matches(updated, expected)
+
+ def test_i_can_update_range(self):
+ data = Data(50)
+ range_elt = Input(
+ type="range",
+ name="range_name",
+ min="0",
+ max="100",
+ value="50"
+ )
+
+ binding = Binding(data)
+ mk.manage_binding(range_elt, binding)
+
+ res = binding.update({"range_name": 25})
+
+ expected = [Input(
+ type="range",
+ name="range_name",
+ min="0",
+ max="100",
+ value=25,
+ hx_post=f"{ROUTE_ROOT}{Routes.Bindings}",
+ id=AnyValue(),
+ hx_swap_oob="true"
+ )]
+ assert matches(res, expected)
+
+ def test_i_can_bind_range_with_label(self, user, rt):
"""
Range input should bind with numeric data.
Changing the slider should update the label.
@@ -642,138 +694,23 @@ class TestBindingRadio:
user.should_see("option2")
-class TestBindingButton:
- """Tests for binding Button components."""
-
- def test_i_can_click_button_with_binding(self, user, rt):
- """
- Clicking a button with HTMX should trigger binding updates.
- """
-
- @rt("/")
- def index():
- data = Data("initial")
- button_elt = Button("Click me", hx_post="/update", hx_vals='{"action": "clicked"}')
- label_elt = Label()
-
- mk.manage_binding(button_elt, Binding(data))
- mk.manage_binding(label_elt, Binding(data))
-
- return button_elt, label_elt
-
- @rt("/update")
- def update(action: str):
- data = Data("button clicked")
- label_elt = Label()
- mk.manage_binding(label_elt, Binding(data))
- return label_elt
-
- user.open("/")
- user.should_see("initial")
-
- testable_button = user.find_element("button")
- testable_button.click()
- user.should_see("button clicked")
-
- def test_button_without_htmx_does_nothing(self, user, rt):
- """
- Button without HTMX should not trigger updates.
- """
-
- @rt("/")
- def index():
- data = Data("initial")
- button_elt = Button("Plain button") # No HTMX
- label_elt = Label()
-
- mk.manage_binding(button_elt, Binding(data))
- mk.manage_binding(label_elt, Binding(data))
-
- return button_elt, label_elt
-
- user.open("/")
- user.should_see("initial")
-
- testable_button = user.find_element("button")
- result = testable_button.click()
- assert result is None # No HTMX, no response
-
-
class TestBindingDatalist:
"""Tests for binding Input with Datalist (combobox)."""
- def test_i_can_bind_input_with_datalist(self, user, rt):
- """
- Input with datalist should allow both free text and suggestions.
- """
+ def test_i_can_bind_datalist(self):
+ data = Data(["suggestion2"])
+ datalist = Datalist(
+ Option(value="suggestion1"),
+ id="suggestions"
+ )
- @rt("/")
- def index():
- data = Data("")
- datalist = Datalist(
- Option(value="suggestion1"),
- Option(value="suggestion2"),
- Option(value="suggestion3"),
- id="suggestions"
- )
- input_elt = Input(
- name="input_name",
- list="suggestions"
- )
- label_elt = Label()
-
- mk.manage_binding(input_elt, Binding(data))
- mk.manage_binding(label_elt, Binding(data))
-
- return input_elt, datalist, label_elt
+ updated = mk.manage_binding(datalist, Binding(data))
+ expected = Datalist(
+ Option(value="suggestion2"),
+ id="suggestions"
+ )
- user.open("/")
- user.should_see("")
-
- testable_input = user.find_element("input[list='suggestions']")
-
- # Can type free text
- testable_input.send("custom value")
- user.should_see("custom value")
-
- # Can select from suggestions
- testable_input.select_suggestion("suggestion2")
- user.should_see("suggestion2")
-
- def test_datalist_suggestions_are_available(self, user, rt):
- """
- Datalist suggestions should be accessible for validation.
- """
-
- @rt("/")
- def index():
- data = Data("")
- datalist = Datalist(
- Option(value="apple"),
- Option(value="banana"),
- Option(value="cherry"),
- id="fruits"
- )
- input_elt = Input(
- name="input_name",
- list="fruits"
- )
- label_elt = Label()
-
- mk.manage_binding(input_elt, Binding(data))
- mk.manage_binding(label_elt, Binding(data))
-
- return input_elt, datalist, label_elt
-
- user.open("/")
-
- testable_input = user.find_element("input[list='fruits']")
-
- # Check that suggestions are available
- suggestions = testable_input.suggestions
- assert "apple" in suggestions
- assert "banana" in suggestions
- assert "cherry" in suggestions
+ assert matches(updated, expected)
class TestBindingEdgeCases:
diff --git a/tests/testclient/test_testable_datalist.py b/tests/testclient/test_testable_datalist.py
deleted file mode 100644
index 9228374..0000000
--- a/tests/testclient/test_testable_datalist.py
+++ /dev/null
@@ -1,124 +0,0 @@
-"""
-Comprehensive binding tests for all bindable FastHTML components.
-
-This test suite covers:
-- Input (text) - already tested
-- Checkbox - already tested
-- Textarea
-- Select (single)
-- Select (multiple)
-- Range (slider)
-- Radio buttons
-- Button
-- Input with Datalist (combobox)
-"""
-
-from dataclasses import dataclass
-
-from fasthtml.components import (
- Input, Label, Option, Datalist
-)
-
-from myfasthtml.controls.helpers import mk
-from myfasthtml.core.bindings import Binding
-
-
-@dataclass
-class Data:
- value: str = "hello world"
-
-
-@dataclass
-class NumericData:
- value: int = 50
-
-
-@dataclass
-class BoolData:
- value: bool = True
-
-
-@dataclass
-class ListData:
- value: list = None
-
- def __post_init__(self):
- if self.value is None:
- self.value = []
-
-
-class TestBindingDatalist:
- """Tests for binding Input with Datalist (combobox)."""
-
- def test_i_can_bind_input_with_datalist(self, user, rt):
- """
- Input with datalist should allow both free text and suggestions.
- """
-
- @rt("/")
- def index():
- data = Data("")
- datalist = Datalist(
- Option(value="suggestion1"),
- Option(value="suggestion2"),
- Option(value="suggestion3"),
- id="suggestions"
- )
- input_elt = Input(
- name="input_name",
- list="suggestions"
- )
- label_elt = Label()
-
- mk.manage_binding(input_elt, Binding(data))
- mk.manage_binding(label_elt, Binding(data))
-
- return input_elt, datalist, label_elt
-
- user.open("/")
- user.should_see("")
-
- testable_input = user.find_element("input[list='suggestions']")
-
- # Can type free text
- testable_input.send("custom value")
- user.should_see("custom value")
-
- # Can select from suggestions
- testable_input.select_suggestion("suggestion2")
- user.should_see("suggestion2")
-
- def test_datalist_suggestions_are_available(self, user, rt):
- """
- Datalist suggestions should be accessible for validation.
- """
-
- @rt("/")
- def index():
- data = Data("")
- datalist = Datalist(
- Option(value="apple"),
- Option(value="banana"),
- Option(value="cherry"),
- id="fruits"
- )
- input_elt = Input(
- name="input_name",
- list="fruits"
- )
- label_elt = Label()
-
- mk.manage_binding(input_elt, Binding(data))
- mk.manage_binding(label_elt, Binding(data))
-
- return input_elt, datalist, label_elt
-
- user.open("/")
-
- testable_input = user.find_element("input[list='fruits']")
-
- # Check that suggestions are available
- suggestions = testable_input.suggestions
- assert "apple" in suggestions
- assert "banana" in suggestions
- assert "cherry" in suggestions
diff --git a/tests/testclient/test_testable_range.py b/tests/testclient/test_testable_range.py
new file mode 100644
index 0000000..466cbb8
--- /dev/null
+++ b/tests/testclient/test_testable_range.py
@@ -0,0 +1,72 @@
+from dataclasses import dataclass
+
+import pytest
+from fasthtml.fastapp import fast_app
+
+from myfasthtml.test.testclient import MyTestClient, TestableRange
+
+
+@dataclass
+class Data:
+ value: str = "hello world"
+
+
+@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_range(test_client):
+ html = ''''''
+
+ input_elt = TestableRange(test_client, html)
+
+ assert input_elt.name == "range_name"
+ assert input_elt.value == 50
+ assert input_elt.min_value == 0
+ assert input_elt.max_value == 100
+ assert input_elt.step == 10
+
+
+@pytest.mark.parametrize("value, expected", [
+ (30, 30),
+ (24, 20), # step 10
+ (-10, 0), # min 0
+ (110, 100), # max 100
+])
+def test_i_can_set_value(test_client, value, expected):
+ html = ''''''
+
+ input_elt = TestableRange(test_client, html)
+
+ input_elt.set(value)
+ assert input_elt.value == expected
+
+
+def test_i_can_increase_value(test_client):
+ html = ''''''
+
+ input_elt = TestableRange(test_client, html)
+
+ input_elt.increase()
+ assert input_elt.value == 60
+
+
+def test_i_can_decrease_value(test_client):
+ html = ''''''
+
+ input_elt = TestableRange(test_client, html)
+
+ input_elt.decrease()
+ assert input_elt.value == 40
diff --git a/tests/testclient/test_testable_textarea.py b/tests/testclient/test_testable_textarea.py
index c7860f5..23c31d3 100644
--- a/tests/testclient/test_testable_textarea.py
+++ b/tests/testclient/test_testable_textarea.py
@@ -34,13 +34,3 @@ def test_i_can_read_input(test_client):
assert input_elt.name == "textarea_name"
assert input_elt.value == "Lorem ipsum"
-
-
-@pytest.mark.skip("To update later")
-def test_i_can_read_input_with_label(test_client):
- html = ''''''
-
- input_elt = TestableTextarea(test_client, html)
- assert input_elt.fields_mapping == {"Text Area": "textarea_name"}
- assert input_elt.name == "textarea_name"
- assert input_elt.value == "Lorem ipsum"
\ No newline at end of file