From e52267477cb5b06b2db73ded6e9b4acde1e6cf39 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Mon, 11 Dec 2023 14:37:57 -0700 Subject: [PATCH] [REF-1417] Convert underscore-prefixed style props to pseudo selector (#2266) --- integration/test_input.py | 21 ++ integration/test_var_operations.py | 2 +- reflex/components/component.py | 4 +- reflex/style.py | 130 +++++++++- reflex/utils/format.py | 14 +- tests/test_style.py | 378 +++++++++++++++++++++++++++++ tests/utils/test_format.py | 8 + 7 files changed, 539 insertions(+), 18 deletions(-) diff --git a/integration/test_input.py b/integration/test_input.py index 4a5179850..2ca15424e 100644 --- a/integration/test_input.py +++ b/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) diff --git a/integration/test_var_operations.py b/integration/test_var_operations.py index 374344a87..a0f245fe4 100644 --- a/integration/test_var_operations.py +++ b/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, diff --git a/reflex/components/component.py b/reflex/components/component.py index c629baca6..22e59a72e 100644 --- a/reflex/components/component.py +++ b/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. diff --git a/reflex/style.py b/reflex/style.py index 1e5d94fa2..96bb1ebdb 100644 --- a/reflex/style.py +++ b/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 diff --git a/reflex/utils/format.py b/reflex/utils/format.py index 4d95d44d9..84364b600 100644 --- a/reflex/utils/format.py +++ b/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""" (?.*?)? # 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('`"', "`") ) diff --git a/tests/test_style.py b/tests/test_style.py index a8fcf6839..40473b43b 100644 --- a/tests/test_style.py +++ b/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) diff --git a/tests/utils/test_format.py b/tests/utils/test_format.py index e528536a1..f56b4d22b 100644 --- a/tests/utils/test_format.py +++ b/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):