diff --git a/src/idom/widgets.py b/src/idom/widgets.py
index 741f744b5..2564ac945 100644
--- a/src/idom/widgets.py
+++ b/src/idom/widgets.py
@@ -1,7 +1,20 @@
from __future__ import annotations
from base64 import b64encode
-from typing import Any, Callable, Dict, Optional, Set, Tuple, Union
+from typing import (
+ Any,
+ Callable,
+ Dict,
+ List,
+ Optional,
+ Sequence,
+ Set,
+ Tuple,
+ TypeVar,
+ Union,
+)
+
+from typing_extensions import Protocol
import idom
@@ -35,28 +48,63 @@ def image(
return {"tagName": "img", "attributes": {"src": src, **(attributes or {})}}
-@component
-def Input(
- callback: Callable[[str], None],
- type: str,
- value: str = "",
- attributes: Optional[Dict[str, Any]] = None,
- cast: Optional[Callable[[str], Any]] = None,
+_Value = TypeVar("_Value")
+
+
+def use_linked_inputs(
+ attributes: Sequence[Dict[str, Any]],
+ on_change: Callable[[_Value], None] = lambda value: None,
+ cast: _CastFunc[_Value] = lambda value: value,
+ initial_value: str = "",
ignore_empty: bool = True,
-) -> VdomDict:
- """Utility for making an ```` with a callback"""
- attrs = attributes or {}
- value, set_value = idom.hooks.use_state(value)
-
- def on_change(event: Dict[str, Any]) -> None:
- value = event["target"]["value"]
- set_value(value)
- if not value and ignore_empty:
- return
- callback(value if cast is None else cast(value))
-
- attributes = {**attrs, "type": type, "value": value, "onChange": on_change}
- return html.input(attributes)
+) -> List[VdomDict]:
+ """Return a list of linked inputs equal to the number of given attributes.
+
+ Parameters:
+ attributes:
+ That attributes of each returned input element. If the number of generated
+ inputs is variable, you may need to assign each one a
+ :ref:`key ` by including a ``"key"`` in each
+ attribute dictionary.
+ on_change:
+ A callback which is triggered when any input is changed. This callback need
+ not update the 'value' field in the attributes of the inputs since that is
+ handled automatically.
+ cast:
+ Cast the 'value' of changed inputs that is passed to ``on_change``.
+ initial_value:
+ Initialize the 'value' field of the inputs.
+ ignore_empty:
+ Do not trigger ``on_change`` if the 'value' is an empty string.
+ """
+ value, set_value = idom.hooks.use_state(initial_value)
+
+ def sync_inputs(event: Dict[str, Any]) -> None:
+ new_value = event["value"]
+ set_value(new_value)
+ if not new_value and ignore_empty:
+ return None
+ on_change(cast(new_value))
+
+ inputs: list[VdomDict] = []
+ for attrs in attributes:
+ # we're going to mutate this so copy it
+ attrs = attrs.copy()
+
+ key = attrs.pop("key", None)
+ attrs.update({"onChange": sync_inputs, "value": value})
+
+ inputs.append(html.input(attrs, key=key))
+
+ return inputs
+
+
+_CastTo = TypeVar("_CastTo", covariant=True)
+
+
+class _CastFunc(Protocol[_CastTo]):
+ def __call__(self, value: str) -> _CastTo:
+ ...
MountFunc = Callable[[ComponentConstructor], None]
diff --git a/tests/test_widgets.py b/tests/test_widgets.py
index 867883186..269dec0ef 100644
--- a/tests/test_widgets.py
+++ b/tests/test_widgets.py
@@ -1,4 +1,3 @@
-import time
from base64 import b64encode
from pathlib import Path
@@ -82,71 +81,102 @@ def test_image_from_bytes(driver, display):
assert BASE64_IMAGE_SRC in client_img.get_attribute("src")
-def test_input_callback(driver, driver_wait, display):
- inp_ref = idom.Ref(None)
+def test_use_linked_inputs(driver, driver_wait, display):
+ @idom.component
+ def SomeComponent():
+ i_1, i_2 = idom.widgets.use_linked_inputs([{"id": "i_1"}, {"id": "i_2"}])
+ return idom.html.div(i_1, i_2)
+
+ display(SomeComponent)
+
+ input_1 = driver.find_element("id", "i_1")
+ input_2 = driver.find_element("id", "i_2")
+
+ send_keys(input_1, "hello")
+
+ driver_wait.until(lambda d: input_1.get_attribute("value") == "hello")
+ driver_wait.until(lambda d: input_2.get_attribute("value") == "hello")
+
+ send_keys(input_2, " world")
+
+ driver_wait.until(lambda d: input_1.get_attribute("value") == "hello world")
+ driver_wait.until(lambda d: input_2.get_attribute("value") == "hello world")
+
+
+def test_use_linked_inputs_on_change(driver, driver_wait, display):
+ value = idom.Ref(None)
+
+ @idom.component
+ def SomeComponent():
+ i_1, i_2 = idom.widgets.use_linked_inputs(
+ [{"id": "i_1"}, {"id": "i_2"}],
+ on_change=value.set_current,
+ )
+ return idom.html.div(i_1, i_2)
+
+ display(SomeComponent)
- display(
- lambda: idom.widgets.Input(
- lambda value: setattr(inp_ref, "current", value),
- "text",
- "initial-value",
- {"id": "inp"},
+ input_1 = driver.find_element("id", "i_1")
+ input_2 = driver.find_element("id", "i_2")
+
+ send_keys(input_1, "hello")
+
+ driver_wait.until(lambda d: value.current == "hello")
+
+ send_keys(input_2, " world")
+
+ driver_wait.until(lambda d: value.current == "hello world")
+
+
+def test_use_linked_inputs_on_change_with_cast(driver, driver_wait, display):
+ value = idom.Ref(None)
+
+ @idom.component
+ def SomeComponent():
+ i_1, i_2 = idom.widgets.use_linked_inputs(
+ [{"id": "i_1"}, {"id": "i_2"}], on_change=value.set_current, cast=int
)
- )
+ return idom.html.div(i_1, i_2)
+
+ display(SomeComponent)
+
+ input_1 = driver.find_element("id", "i_1")
+ input_2 = driver.find_element("id", "i_2")
- client_inp = driver.find_element("id", "inp")
- assert client_inp.get_attribute("value") == "initial-value"
+ send_keys(input_1, "1")
- client_inp.clear()
- send_keys(client_inp, "new-value-1")
- driver_wait.until(lambda dvr: inp_ref.current == "new-value-1")
+ driver_wait.until(lambda d: value.current == 1)
- client_inp.clear()
- send_keys(client_inp, "new-value-2")
- driver_wait.until(lambda dvr: client_inp.get_attribute("value") == "new-value-2")
+ send_keys(input_2, "2")
+ driver_wait.until(lambda d: value.current == 12)
-def test_input_ignore_empty(driver, driver_wait, display):
- # ignore empty since that's an invalid float
- inp_ingore_ref = idom.Ref("1")
- inp_not_ignore_ref = idom.Ref("1")
+
+def test_use_linked_inputs_ignore_empty(driver, driver_wait, display):
+ value = idom.Ref(None)
@idom.component
- def InputWrapper():
- return idom.html.div(
- idom.widgets.Input(
- lambda value: setattr(inp_ingore_ref, "current", value),
- "number",
- inp_ingore_ref.current,
- {"id": "inp-ignore"},
- ignore_empty=True,
- ),
- idom.widgets.Input(
- lambda value: setattr(inp_not_ignore_ref, "current", value),
- "number",
- inp_not_ignore_ref.current,
- {"id": "inp-not-ignore"},
- ignore_empty=False,
- ),
+ def SomeComponent():
+ i_1, i_2 = idom.widgets.use_linked_inputs(
+ [{"id": "i_1"}, {"id": "i_2"}],
+ on_change=value.set_current,
+ ignore_empty=True,
)
+ return idom.html.div(i_1, i_2)
+
+ display(SomeComponent)
+
+ input_1 = driver.find_element("id", "i_1")
+ input_2 = driver.find_element("id", "i_2")
- display(InputWrapper)
+ send_keys(input_1, "1")
- client_inp_ignore = driver.find_element("id", "inp-ignore")
- client_inp_not_ignore = driver.find_element("id", "inp-not-ignore")
+ driver_wait.until(lambda d: value.current == "1")
- send_keys(client_inp_ignore, Keys.BACKSPACE)
- time.sleep(0.1) # waiting and deleting again seems to decrease flakiness
- send_keys(client_inp_ignore, Keys.BACKSPACE)
+ send_keys(input_2, Keys.BACKSPACE)
- send_keys(client_inp_not_ignore, Keys.BACKSPACE)
- time.sleep(0.1) # waiting and deleting again seems to decrease flakiness
- send_keys(client_inp_not_ignore, Keys.BACKSPACE)
+ assert value.current == "1"
- driver_wait.until(lambda drv: client_inp_ignore.get_attribute("value") == "")
- driver_wait.until(lambda drv: client_inp_not_ignore.get_attribute("value") == "")
+ send_keys(input_1, "2")
- # ignored empty value on change
- assert inp_ingore_ref.current == "1"
- # did not ignore empty value on change
- assert inp_not_ignore_ref.current == ""
+ driver_wait.until(lambda d: value.current == "2")