[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(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)

View File

@ -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,

View File

@ -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.

View File

@ -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

View File

@ -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('`"', "`")
)

View File

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

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"),
("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):