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")