Pārlūkot izejas kodu

[REF-1417] Convert underscore-prefixed style props to pseudo selector (#2266)

Masen Furer 1 gadu atpakaļ
vecāks
revīzija
e52267477c

+ 21 - 0
integration/test_input.py

@@ -31,6 +31,12 @@ def FullyControlledInput():
             ),
             rx.input(value=State.text, id="value_input", is_read_only=True),
             rx.input(on_change=State.set_text, id="on_change_input"),  # type: ignore
+            rx.el.input(
+                value=State.text,
+                id="plain_value_input",
+                disabled=True,
+                _disabled={"background_color": "#EEE"},
+            ),
             rx.button("CLEAR", on_click=rx.set_value("on_change_input", "")),
         )
 
@@ -76,9 +82,15 @@ async def test_fully_controlled_input(fully_controlled_input: AppHarness):
     debounce_input = driver.find_element(By.ID, "debounce_input_input")
     value_input = driver.find_element(By.ID, "value_input")
     on_change_input = driver.find_element(By.ID, "on_change_input")
+    plain_value_input = driver.find_element(By.ID, "plain_value_input")
     clear_button = driver.find_element(By.TAG_NAME, "button")
     assert fully_controlled_input.poll_for_value(debounce_input) == "initial"
     assert fully_controlled_input.poll_for_value(value_input) == "initial"
+    assert fully_controlled_input.poll_for_value(plain_value_input) == "initial"
+    assert (
+        plain_value_input.value_of_css_property("background-color")
+        == "rgba(238, 238, 238, 1)"
+    )
 
     # move cursor to home, then to the right and type characters
     debounce_input.send_keys(Keys.HOME, Keys.ARROW_RIGHT)
@@ -89,6 +101,7 @@ async def test_fully_controlled_input(fully_controlled_input: AppHarness):
         "state"
     ].text == "ifoonitial"
     assert fully_controlled_input.poll_for_value(value_input) == "ifoonitial"
+    assert fully_controlled_input.poll_for_value(plain_value_input) == "ifoonitial"
 
     # clear the input on the backend
     async with fully_controlled_input.modify_state(token) as state:
@@ -109,6 +122,10 @@ async def test_fully_controlled_input(fully_controlled_input: AppHarness):
         "state"
     ].text == "getting testing done"
     assert fully_controlled_input.poll_for_value(value_input) == "getting testing done"
+    assert (
+        fully_controlled_input.poll_for_value(plain_value_input)
+        == "getting testing done"
+    )
 
     # type into the on_change input
     on_change_input.send_keys("overwrite the state")
@@ -119,6 +136,10 @@ async def test_fully_controlled_input(fully_controlled_input: AppHarness):
         "state"
     ].text == "overwrite the state"
     assert fully_controlled_input.poll_for_value(value_input) == "overwrite the state"
+    assert (
+        fully_controlled_input.poll_for_value(plain_value_input)
+        == "overwrite the state"
+    )
 
     clear_button.click()
     time.sleep(0.5)

+ 1 - 1
integration/test_var_operations.py

@@ -35,7 +35,7 @@ def VarOperations():
     @app.add_page
     def index():
         return rx.vstack(
-            rx.input(
+            rx.el.input(
                 id="token",
                 value=VarOperationState.router.session.client_token,
                 is_read_only=True,

+ 2 - 2
reflex/components/component.py

@@ -40,7 +40,7 @@ from reflex.event import (
     call_event_handler,
     get_handler_args,
 )
-from reflex.style import Style
+from reflex.style import Style, format_as_emotion
 from reflex.utils import console, format, imports, types
 from reflex.utils.imports import ImportVar
 from reflex.utils.serializers import serializer
@@ -624,7 +624,7 @@ class Component(BaseComponent, ABC):
         Returns:
             The dictionary of the component style as value and the style notation as key.
         """
-        return {"css": self.style}
+        return {"css": Var.create(format_as_emotion(self.style))}
 
     def render(self) -> Dict:
         """Render the component.

+ 120 - 10
reflex/style.py

@@ -35,6 +35,66 @@ toggle_color_mode = BaseVar(
     _var_data=color_mode_var_data,
 )
 
+breakpoints = ["0", "30em", "48em", "62em", "80em", "96em"]
+
+
+def media_query(breakpoint_index: int):
+    """Create a media query selector.
+
+    Args:
+        breakpoint_index: The index of the breakpoint to use.
+
+    Returns:
+        The media query selector used as a key in emotion css dict.
+    """
+    return f"@media screen and (min-width: {breakpoints[breakpoint_index]})"
+
+
+def convert_item(style_item: str | Var) -> tuple[str, VarData | None]:
+    """Format a single value in a style dictionary.
+
+    Args:
+        style_item: The style item to format.
+
+    Returns:
+        The formatted style item and any associated VarData.
+    """
+    if isinstance(style_item, Var):
+        # If the value is a Var, extract the var_data and cast as str.
+        return str(style_item), style_item._var_data
+
+    # Otherwise, convert to Var to collapse VarData encoded in f-string.
+    new_var = Var.create(style_item)
+    if new_var is not None and new_var._var_data:
+        # The wrapped backtick is used to identify the Var for interpolation.
+        return f"`{str(new_var)}`", new_var._var_data
+
+    return style_item, None
+
+
+def convert_list(
+    responsive_list: list[str | dict | Var],
+) -> tuple[list[str | dict], VarData | None]:
+    """Format a responsive value list.
+
+    Args:
+        responsive_list: The raw responsive value list (one value per breakpoint).
+
+    Returns:
+        The recursively converted responsive value list and any associated VarData.
+    """
+    converted_value = []
+    item_var_datas = []
+    for responsive_item in responsive_list:
+        if isinstance(responsive_item, dict):
+            # Recursively format nested style dictionaries.
+            item, item_var_data = convert(responsive_item)
+        else:
+            item, item_var_data = convert_item(responsive_item)
+        converted_value.append(item)
+        item_var_datas.append(item_var_data)
+    return converted_value, VarData.merge(*item_var_datas)
+
 
 def convert(style_dict):
     """Format a style dictionary.
@@ -49,20 +109,14 @@ def convert(style_dict):
     out = {}
     for key, value in style_dict.items():
         key = format.to_camel_case(key)
-        new_var_data = None
         if isinstance(value, dict):
             # Recursively format nested style dictionaries.
             out[key], new_var_data = convert(value)
-        elif isinstance(value, Var):
-            # If the value is a Var, extract the var_data and cast as str.
-            new_var_data = value._var_data
-            out[key] = str(value)
+        elif isinstance(value, list):
+            # Responsive value is a list of dict or value
+            out[key], new_var_data = convert_list(value)
         else:
-            # Otherwise, convert to Var to collapse VarData encoded in f-string.
-            new_var = Var.create(value)
-            if new_var is not None:
-                new_var_data = new_var._var_data
-            out[key] = value
+            out[key], new_var_data = convert_item(value)
         # Combine all the collected VarData instances.
         var_data = VarData.merge(var_data, new_var_data)
     return out, var_data
@@ -110,3 +164,59 @@ class Style(dict):
             # Carry the imports/hooks when setting a Var as a value.
             self._var_data = VarData.merge(self._var_data, _var._var_data)
         super().__setitem__(key, value)
+
+
+def _format_emotion_style_pseudo_selector(key: str) -> str:
+    """Format a pseudo selector for emotion CSS-in-JS.
+
+    Args:
+        key: Underscore-prefixed or colon-prefixed pseudo selector key (_hover).
+
+    Returns:
+        A self-referential pseudo selector key (&:hover).
+    """
+    prefix = None
+    if key.startswith("_"):
+        # Handle pseudo selectors in chakra style format.
+        prefix = "&:"
+        key = key[1:]
+    if key.startswith(":"):
+        # Handle pseudo selectors and elements in native format.
+        prefix = "&"
+    if prefix is not None:
+        return prefix + format.to_kebab_case(key)
+    return key
+
+
+def format_as_emotion(style_dict: dict[str, Any]) -> dict[str, Any] | None:
+    """Convert the style to an emotion-compatible CSS-in-JS dict.
+
+    Args:
+        style_dict: The style dict to convert.
+
+    Returns:
+        The emotion dict.
+    """
+    emotion_style = {}
+    for orig_key, value in style_dict.items():
+        key = _format_emotion_style_pseudo_selector(orig_key)
+        if isinstance(value, list):
+            # Apply media queries from responsive value list.
+            mbps = {
+                media_query(bp): bp_value
+                if isinstance(bp_value, dict)
+                else {key: bp_value}
+                for bp, bp_value in enumerate(value)
+            }
+            if key.startswith("&:"):
+                emotion_style[key] = mbps
+            else:
+                for mq, style_sub_dict in mbps.items():
+                    emotion_style.setdefault(mq, {}).update(style_sub_dict)
+        elif isinstance(value, dict):
+            # Recursively format nested style dictionaries.
+            emotion_style[key] = format_as_emotion(value)
+        else:
+            emotion_style[key] = value
+    if emotion_style:
+        return emotion_style

+ 9 - 5
reflex/utils/format.py

@@ -625,17 +625,21 @@ def unwrap_vars(value: str) -> str:
         return prefix + re.sub('\\\\"', '"', m.group(2))
 
     # This substitution is necessary to unwrap var values.
-    return re.sub(
-        pattern=r"""
+    return (
+        re.sub(
+            pattern=r"""
             (?<!\\)      # must NOT start with a backslash
             "            # match opening double quote of JSON value
             (<reflex.Var>.*?</reflex.Var>)?  # Optional encoded VarData (non-greedy)
             {(.*?)}      # extract the value between curly braces (non-greedy)
             "            # match must end with an unescaped double quote
         """,
-        repl=unescape_double_quotes_in_var,
-        string=value,
-        flags=re.VERBOSE,
+            repl=unescape_double_quotes_in_var,
+            string=value,
+            flags=re.VERBOSE,
+        )
+        .replace('"`', "`")
+        .replace('`"', "`")
     )
 
 

+ 378 - 0
tests/test_style.py

@@ -1,5 +1,10 @@
+from __future__ import annotations
+
+from typing import Any
+
 import pytest
 
+import reflex as rx
 from reflex import style
 from reflex.vars import Var
 
@@ -8,6 +13,12 @@ test_style = [
     ({"a": Var.create("abc")}, {"a": "abc"}),
     ({"test_case": 1}, {"testCase": 1}),
     ({"test_case": {"a": 1}}, {"testCase": {"a": 1}}),
+    ({":test_case": {"a": 1}}, {":testCase": {"a": 1}}),
+    ({"::test_case": {"a": 1}}, {"::testCase": {"a": 1}}),
+    (
+        {"::-webkit-scrollbar": {"display": "none"}},
+        {"::WebkitScrollbar": {"display": "none"}},
+    ),
 ]
 
 
@@ -38,3 +49,370 @@ def test_create_style(style_dict, expected):
         expected: The expected formatted style.
     """
     assert style.Style(style_dict) == expected
+
+
+def compare_dict_of_var(d1: dict[str, Any], d2: dict[str, Any]):
+    """Compare two dictionaries of Var objects.
+
+    Args:
+        d1: The first dictionary.
+        d2: The second dictionary.
+    """
+    assert len(d1) == len(d2)
+    for key, value in d1.items():
+        assert key in d2
+        if isinstance(value, dict):
+            compare_dict_of_var(value, d2[key])
+        elif isinstance(value, Var):
+            assert value.equals(d2[key])
+        else:
+            assert value == d2[key]
+
+
+@pytest.mark.parametrize(
+    ("kwargs", "style_dict", "expected_get_style"),
+    [
+        ({}, {}, {"css": None}),
+        ({"color": "hotpink"}, {}, {"css": Var.create({"color": "hotpink"})}),
+        ({}, {"color": "red"}, {"css": Var.create({"color": "red"})}),
+        (
+            {"color": "hotpink"},
+            {"color": "red"},
+            {"css": Var.create({"color": "hotpink"})},
+        ),
+        (
+            {"_hover": {"color": "hotpink"}},
+            {},
+            {"css": Var.create({"&:hover": {"color": "hotpink"}})},
+        ),
+        (
+            {},
+            {"_hover": {"color": "red"}},
+            {"css": Var.create({"&:hover": {"color": "red"}})},
+        ),
+        (
+            {},
+            {":hover": {"color": "red"}},
+            {"css": Var.create({"&:hover": {"color": "red"}})},
+        ),
+        (
+            {},
+            {"::-webkit-scrollbar": {"display": "none"}},
+            {"css": Var.create({"&::-webkit-scrollbar": {"display": "none"}})},
+        ),
+        (
+            {},
+            {"::-moz-progress-bar": {"background_color": "red"}},
+            {"css": Var.create({"&::-moz-progress-bar": {"backgroundColor": "red"}})},
+        ),
+        (
+            {"color": ["#111", "#222", "#333", "#444", "#555"]},
+            {},
+            {
+                "css": Var.create(
+                    {
+                        "@media screen and (min-width: 0)": {"color": "#111"},
+                        "@media screen and (min-width: 30em)": {"color": "#222"},
+                        "@media screen and (min-width: 48em)": {"color": "#333"},
+                        "@media screen and (min-width: 62em)": {"color": "#444"},
+                        "@media screen and (min-width: 80em)": {"color": "#555"},
+                    }
+                )
+            },
+        ),
+        (
+            {
+                "color": ["#111", "#222", "#333", "#444", "#555"],
+                "background_color": "#FFF",
+            },
+            {},
+            {
+                "css": Var.create(
+                    {
+                        "@media screen and (min-width: 0)": {"color": "#111"},
+                        "@media screen and (min-width: 30em)": {"color": "#222"},
+                        "@media screen and (min-width: 48em)": {"color": "#333"},
+                        "@media screen and (min-width: 62em)": {"color": "#444"},
+                        "@media screen and (min-width: 80em)": {"color": "#555"},
+                        "backgroundColor": "#FFF",
+                    }
+                )
+            },
+        ),
+        (
+            {
+                "color": ["#111", "#222", "#333", "#444", "#555"],
+                "background_color": ["#FFF", "#EEE", "#DDD", "#CCC", "#BBB"],
+            },
+            {},
+            {
+                "css": Var.create(
+                    {
+                        "@media screen and (min-width: 0)": {
+                            "color": "#111",
+                            "backgroundColor": "#FFF",
+                        },
+                        "@media screen and (min-width: 30em)": {
+                            "color": "#222",
+                            "backgroundColor": "#EEE",
+                        },
+                        "@media screen and (min-width: 48em)": {
+                            "color": "#333",
+                            "backgroundColor": "#DDD",
+                        },
+                        "@media screen and (min-width: 62em)": {
+                            "color": "#444",
+                            "backgroundColor": "#CCC",
+                        },
+                        "@media screen and (min-width: 80em)": {
+                            "color": "#555",
+                            "backgroundColor": "#BBB",
+                        },
+                    }
+                )
+            },
+        ),
+        (
+            {
+                "_hover": [
+                    {"color": "#111"},
+                    {"color": "#222"},
+                    {"color": "#333"},
+                    {"color": "#444"},
+                    {"color": "#555"},
+                ]
+            },
+            {},
+            {
+                "css": Var.create(
+                    {
+                        "&:hover": {
+                            "@media screen and (min-width: 0)": {"color": "#111"},
+                            "@media screen and (min-width: 30em)": {"color": "#222"},
+                            "@media screen and (min-width: 48em)": {"color": "#333"},
+                            "@media screen and (min-width: 62em)": {"color": "#444"},
+                            "@media screen and (min-width: 80em)": {"color": "#555"},
+                        }
+                    }
+                )
+            },
+        ),
+        (
+            {"_hover": {"color": ["#111", "#222", "#333", "#444", "#555"]}},
+            {},
+            {
+                "css": Var.create(
+                    {
+                        "&:hover": {
+                            "@media screen and (min-width: 0)": {"color": "#111"},
+                            "@media screen and (min-width: 30em)": {"color": "#222"},
+                            "@media screen and (min-width: 48em)": {"color": "#333"},
+                            "@media screen and (min-width: 62em)": {"color": "#444"},
+                            "@media screen and (min-width: 80em)": {"color": "#555"},
+                        }
+                    }
+                )
+            },
+        ),
+        (
+            {
+                "_hover": {
+                    "color": ["#111", "#222", "#333", "#444", "#555"],
+                    "background_color": ["#FFF", "#EEE", "#DDD", "#CCC", "#BBB"],
+                }
+            },
+            {},
+            {
+                "css": Var.create(
+                    {
+                        "&:hover": {
+                            "@media screen and (min-width: 0)": {
+                                "color": "#111",
+                                "backgroundColor": "#FFF",
+                            },
+                            "@media screen and (min-width: 30em)": {
+                                "color": "#222",
+                                "backgroundColor": "#EEE",
+                            },
+                            "@media screen and (min-width: 48em)": {
+                                "color": "#333",
+                                "backgroundColor": "#DDD",
+                            },
+                            "@media screen and (min-width: 62em)": {
+                                "color": "#444",
+                                "backgroundColor": "#CCC",
+                            },
+                            "@media screen and (min-width: 80em)": {
+                                "color": "#555",
+                                "backgroundColor": "#BBB",
+                            },
+                        }
+                    }
+                )
+            },
+        ),
+        (
+            {
+                "_hover": {
+                    "color": ["#111", "#222", "#333", "#444", "#555"],
+                    "background_color": "#FFF",
+                }
+            },
+            {},
+            {
+                "css": Var.create(
+                    {
+                        "&:hover": {
+                            "@media screen and (min-width: 0)": {"color": "#111"},
+                            "@media screen and (min-width: 30em)": {"color": "#222"},
+                            "@media screen and (min-width: 48em)": {"color": "#333"},
+                            "@media screen and (min-width: 62em)": {"color": "#444"},
+                            "@media screen and (min-width: 80em)": {"color": "#555"},
+                            "backgroundColor": "#FFF",
+                        }
+                    }
+                )
+            },
+        ),
+    ],
+)
+def test_style_via_component(
+    kwargs: dict[str, Any],
+    style_dict: dict[str, Any],
+    expected_get_style: dict[str, Any],
+):
+    """Pass kwargs and style_dict to a component and assert the final, combined style dict.
+
+    Args:
+        kwargs: The kwargs to pass to the component.
+        style_dict: The style_dict to pass to the component.
+        expected_get_style: The expected style dict.
+    """
+    comp = rx.el.div(style=style_dict, **kwargs)  # type: ignore
+    compare_dict_of_var(comp._get_style(), expected_get_style)
+
+
+class StyleState(rx.State):
+    """Style vars in a substate."""
+
+    color: str = "hotpink"
+    color2: str = "red"
+
+
+@pytest.mark.parametrize(
+    ("kwargs", "expected_get_style"),
+    [
+        (
+            {"color": StyleState.color},
+            {"css": Var.create({"color": StyleState.color})},
+        ),
+        (
+            {"color": f"dark{StyleState.color}"},
+            {"css": Var.create_safe(f'{{"color": `dark{StyleState.color}`}}').to(dict)},
+        ),
+        (
+            {"color": StyleState.color, "_hover": {"color": StyleState.color2}},
+            {
+                "css": Var.create(
+                    {
+                        "color": StyleState.color,
+                        "&:hover": {"color": StyleState.color2},
+                    }
+                )
+            },
+        ),
+        (
+            {"color": [StyleState.color, "gray", StyleState.color2, "yellow", "blue"]},
+            {
+                "css": Var.create(
+                    {
+                        "@media screen and (min-width: 0)": {"color": StyleState.color},
+                        "@media screen and (min-width: 30em)": {"color": "gray"},
+                        "@media screen and (min-width: 48em)": {
+                            "color": StyleState.color2
+                        },
+                        "@media screen and (min-width: 62em)": {"color": "yellow"},
+                        "@media screen and (min-width: 80em)": {"color": "blue"},
+                    }
+                )
+            },
+        ),
+        (
+            {
+                "_hover": [
+                    {"color": StyleState.color},
+                    {"color": StyleState.color2},
+                    {"color": "#333"},
+                    {"color": "#444"},
+                    {"color": "#555"},
+                ]
+            },
+            {
+                "css": Var.create(
+                    {
+                        "&:hover": {
+                            "@media screen and (min-width: 0)": {
+                                "color": StyleState.color
+                            },
+                            "@media screen and (min-width: 30em)": {
+                                "color": StyleState.color2
+                            },
+                            "@media screen and (min-width: 48em)": {"color": "#333"},
+                            "@media screen and (min-width: 62em)": {"color": "#444"},
+                            "@media screen and (min-width: 80em)": {"color": "#555"},
+                        }
+                    }
+                )
+            },
+        ),
+        (
+            {
+                "_hover": {
+                    "color": [
+                        StyleState.color,
+                        StyleState.color2,
+                        "#333",
+                        "#444",
+                        "#555",
+                    ]
+                }
+            },
+            {
+                "css": Var.create(
+                    {
+                        "&:hover": {
+                            "@media screen and (min-width: 0)": {
+                                "color": StyleState.color
+                            },
+                            "@media screen and (min-width: 30em)": {
+                                "color": StyleState.color2
+                            },
+                            "@media screen and (min-width: 48em)": {"color": "#333"},
+                            "@media screen and (min-width: 62em)": {"color": "#444"},
+                            "@media screen and (min-width: 80em)": {"color": "#555"},
+                        }
+                    }
+                )
+            },
+        ),
+    ],
+)
+def test_style_via_component_with_state(
+    kwargs: dict[str, Any],
+    expected_get_style: dict[str, Any],
+):
+    """Pass kwargs to a component with state vars and assert the final, combined style dict.
+
+    Args:
+        kwargs: The kwargs to pass to the component.
+        expected_get_style: The expected style dict.
+    """
+    comp = rx.el.div(**kwargs)
+
+    assert comp.style._var_data == expected_get_style["css"]._var_data
+    # Remove the _var_data from the expected style, since the emotion-formatted
+    # style dict won't actually have it.
+    expected_get_style["css"]._var_data = None
+
+    # Assert that style values are equal.
+    compare_dict_of_var(comp._get_style(), expected_get_style)

+ 8 - 0
tests/utils/test_format.py

@@ -125,6 +125,8 @@ def test_indent(text: str, indent_level: int, expected: str, windows_platform: b
         ("__start_with_double_underscore", "__start_with_double_underscore"),
         ("kebab-case", "kebab_case"),
         ("double-kebab-case", "double_kebab_case"),
+        (":start-with-colon", ":start_with_colon"),
+        (":-start-with-colon-dash", ":_start_with_colon_dash"),
     ],
 )
 def test_to_snake_case(input: str, output: str):
@@ -153,6 +155,8 @@ def test_to_snake_case(input: str, output: str):
         ("--starts-with-double-hyphen", "--startsWithDoubleHyphen"),
         ("_starts_with_underscore", "_startsWithUnderscore"),
         ("__starts_with_double_underscore", "__startsWithDoubleUnderscore"),
+        (":start-with-colon", ":startWithColon"),
+        (":-start-with-colon-dash", ":StartWithColonDash"),
     ],
 )
 def test_to_camel_case(input: str, output: str):
@@ -193,6 +197,10 @@ def test_to_title_case(input: str, output: str):
         ("Hello", "hello"),
         ("snake_case", "snake-case"),
         ("snake_case_two", "snake-case-two"),
+        (":startWithColon", ":start-with-colon"),
+        (":StartWithColonDash", ":-start-with-colon-dash"),
+        (":start_with_colon", ":start-with-colon"),
+        (":_start_with_colon_dash", ":-start-with-colon-dash"),
     ],
 )
 def test_to_kebab_case(input: str, output: str):