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

This commit is contained in:
Masen Furer 2023-12-11 14:37:57 -07:00 committed by GitHub
parent 421be5748b
commit e52267477c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 539 additions and 18 deletions

View File

@ -31,6 +31,12 @@ def FullyControlledInput():
), ),
rx.input(value=State.text, id="value_input", is_read_only=True), 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.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", "")), 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") debounce_input = driver.find_element(By.ID, "debounce_input_input")
value_input = driver.find_element(By.ID, "value_input") value_input = driver.find_element(By.ID, "value_input")
on_change_input = driver.find_element(By.ID, "on_change_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") 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(debounce_input) == "initial"
assert fully_controlled_input.poll_for_value(value_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 # move cursor to home, then to the right and type characters
debounce_input.send_keys(Keys.HOME, Keys.ARROW_RIGHT) debounce_input.send_keys(Keys.HOME, Keys.ARROW_RIGHT)
@ -89,6 +101,7 @@ async def test_fully_controlled_input(fully_controlled_input: AppHarness):
"state" "state"
].text == "ifoonitial" ].text == "ifoonitial"
assert fully_controlled_input.poll_for_value(value_input) == "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 # clear the input on the backend
async with fully_controlled_input.modify_state(token) as state: 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" "state"
].text == "getting testing done" ].text == "getting testing done"
assert fully_controlled_input.poll_for_value(value_input) == "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 # type into the on_change input
on_change_input.send_keys("overwrite the state") on_change_input.send_keys("overwrite the state")
@ -119,6 +136,10 @@ async def test_fully_controlled_input(fully_controlled_input: AppHarness):
"state" "state"
].text == "overwrite the state" ].text == "overwrite the state"
assert fully_controlled_input.poll_for_value(value_input) == "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() clear_button.click()
time.sleep(0.5) time.sleep(0.5)

View File

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

View File

@ -40,7 +40,7 @@ from reflex.event import (
call_event_handler, call_event_handler,
get_handler_args, 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 import console, format, imports, types
from reflex.utils.imports import ImportVar from reflex.utils.imports import ImportVar
from reflex.utils.serializers import serializer from reflex.utils.serializers import serializer
@ -624,7 +624,7 @@ class Component(BaseComponent, ABC):
Returns: Returns:
The dictionary of the component style as value and the style notation as key. 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: def render(self) -> Dict:
"""Render the component. """Render the component.

View File

@ -35,6 +35,66 @@ toggle_color_mode = BaseVar(
_var_data=color_mode_var_data, _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): def convert(style_dict):
"""Format a style dictionary. """Format a style dictionary.
@ -49,20 +109,14 @@ def convert(style_dict):
out = {} out = {}
for key, value in style_dict.items(): for key, value in style_dict.items():
key = format.to_camel_case(key) key = format.to_camel_case(key)
new_var_data = None
if isinstance(value, dict): if isinstance(value, dict):
# Recursively format nested style dictionaries. # Recursively format nested style dictionaries.
out[key], new_var_data = convert(value) out[key], new_var_data = convert(value)
elif isinstance(value, Var): elif isinstance(value, list):
# If the value is a Var, extract the var_data and cast as str. # Responsive value is a list of dict or value
new_var_data = value._var_data out[key], new_var_data = convert_list(value)
out[key] = str(value)
else: else:
# Otherwise, convert to Var to collapse VarData encoded in f-string. out[key], new_var_data = convert_item(value)
new_var = Var.create(value)
if new_var is not None:
new_var_data = new_var._var_data
out[key] = value
# Combine all the collected VarData instances. # Combine all the collected VarData instances.
var_data = VarData.merge(var_data, new_var_data) var_data = VarData.merge(var_data, new_var_data)
return out, var_data return out, var_data
@ -110,3 +164,59 @@ class Style(dict):
# Carry the imports/hooks when setting a Var as a value. # Carry the imports/hooks when setting a Var as a value.
self._var_data = VarData.merge(self._var_data, _var._var_data) self._var_data = VarData.merge(self._var_data, _var._var_data)
super().__setitem__(key, value) 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

View File

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

View File

@ -1,5 +1,10 @@
from __future__ import annotations
from typing import Any
import pytest import pytest
import reflex as rx
from reflex import style from reflex import style
from reflex.vars import Var from reflex.vars import Var
@ -8,6 +13,12 @@ test_style = [
({"a": Var.create("abc")}, {"a": "abc"}), ({"a": Var.create("abc")}, {"a": "abc"}),
({"test_case": 1}, {"testCase": 1}), ({"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}}),
({"::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. expected: The expected formatted style.
""" """
assert style.Style(style_dict) == expected 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)

View File

@ -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"), ("__start_with_double_underscore", "__start_with_double_underscore"),
("kebab-case", "kebab_case"), ("kebab-case", "kebab_case"),
("double-kebab-case", "double_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): 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-double-hyphen", "--startsWithDoubleHyphen"),
("_starts_with_underscore", "_startsWithUnderscore"), ("_starts_with_underscore", "_startsWithUnderscore"),
("__starts_with_double_underscore", "__startsWithDoubleUnderscore"), ("__starts_with_double_underscore", "__startsWithDoubleUnderscore"),
(":start-with-colon", ":startWithColon"),
(":-start-with-colon-dash", ":StartWithColonDash"),
], ],
) )
def test_to_camel_case(input: str, output: str): def test_to_camel_case(input: str, output: str):
@ -193,6 +197,10 @@ def test_to_title_case(input: str, output: str):
("Hello", "hello"), ("Hello", "hello"),
("snake_case", "snake-case"), ("snake_case", "snake-case"),
("snake_case_two", "snake-case-two"), ("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): def test_to_kebab_case(input: str, output: str):