406 lines
13 KiB
Python
406 lines
13 KiB
Python
"""Handle styling."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Literal, Tuple, Type
|
|
|
|
from reflex import constants
|
|
from reflex.components.core.breakpoints import Breakpoints, breakpoints_values
|
|
from reflex.event import EventChain, EventHandler, EventSpec, run_script
|
|
from reflex.utils import format
|
|
from reflex.utils.exceptions import ReflexError
|
|
from reflex.utils.imports import ImportVar
|
|
from reflex.utils.types import get_origin
|
|
from reflex.vars import VarData
|
|
from reflex.vars.base import LiteralVar, Var
|
|
from reflex.vars.function import FunctionVar
|
|
from reflex.vars.object import ObjectVar
|
|
|
|
SYSTEM_COLOR_MODE: str = "system"
|
|
LIGHT_COLOR_MODE: str = "light"
|
|
DARK_COLOR_MODE: str = "dark"
|
|
LiteralColorMode = Literal["system", "light", "dark"]
|
|
|
|
# Reference the global ColorModeContext
|
|
color_mode_imports = {
|
|
f"$/{constants.Dirs.CONTEXTS_PATH}": [ImportVar(tag="ColorModeContext")],
|
|
"react": [ImportVar(tag="useContext")],
|
|
}
|
|
|
|
|
|
def _color_mode_var(_js_expr: str, _var_type: Type = str) -> Var:
|
|
"""Create a Var that destructs the _js_expr from ColorModeContext.
|
|
|
|
Args:
|
|
_js_expr: The name of the variable to get from ColorModeContext.
|
|
_var_type: The type of the Var.
|
|
|
|
Returns:
|
|
The Var that resolves to the color mode.
|
|
"""
|
|
return Var(
|
|
_js_expr=_js_expr,
|
|
_var_type=_var_type,
|
|
_var_data=VarData(
|
|
imports=color_mode_imports,
|
|
hooks={f"const {{ {_js_expr} }} = useContext(ColorModeContext)": None},
|
|
),
|
|
).guess_type()
|
|
|
|
|
|
def set_color_mode(
|
|
new_color_mode: LiteralColorMode | Var[LiteralColorMode],
|
|
) -> EventSpec:
|
|
"""Create an EventSpec Var that sets the color mode to a specific value.
|
|
|
|
Note: `set_color_mode` is not a real event and cannot be triggered from a
|
|
backend event handler.
|
|
|
|
Args:
|
|
new_color_mode: The color mode to set.
|
|
|
|
Returns:
|
|
The EventSpec Var that can be passed to an event trigger.
|
|
"""
|
|
base_setter = _color_mode_var(
|
|
_js_expr=constants.ColorMode.SET,
|
|
).to(FunctionVar)
|
|
|
|
return run_script(
|
|
base_setter.call(new_color_mode),
|
|
)
|
|
|
|
|
|
# Var resolves to the current color mode for the app ("light", "dark" or "system")
|
|
color_mode = _color_mode_var(_js_expr=constants.ColorMode.NAME)
|
|
# Var resolves to the resolved color mode for the app ("light" or "dark")
|
|
resolved_color_mode = _color_mode_var(_js_expr=constants.ColorMode.RESOLVED_NAME)
|
|
# Var resolves to a function invocation that toggles the color mode
|
|
toggle_color_mode = _color_mode_var(
|
|
_js_expr=constants.ColorMode.TOGGLE,
|
|
_var_type=EventChain,
|
|
)
|
|
|
|
STYLE_PROP_SHORTHAND_MAPPING = {
|
|
"paddingX": ("paddingInlineStart", "paddingInlineEnd"),
|
|
"paddingY": ("paddingTop", "paddingBottom"),
|
|
"marginX": ("marginInlineStart", "marginInlineEnd"),
|
|
"marginY": ("marginTop", "marginBottom"),
|
|
"bg": ("background",),
|
|
"bgColor": ("backgroundColor",),
|
|
# Radix components derive their font from this CSS var, not inherited from body or class.
|
|
"fontFamily": ("fontFamily", "--default-font-family"),
|
|
}
|
|
|
|
|
|
def media_query(breakpoint_expr: str):
|
|
"""Create a media query selector.
|
|
|
|
Args:
|
|
breakpoint_expr: The CSS expression representing the breakpoint.
|
|
|
|
Returns:
|
|
The media query selector used as a key in emotion css dict.
|
|
"""
|
|
return f"@media screen and (min-width: {breakpoint_expr})"
|
|
|
|
|
|
def convert_item(
|
|
style_item: int | str | Var,
|
|
) -> tuple[str | Var, 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.
|
|
|
|
Raises:
|
|
ReflexError: If an EventHandler is used as a style value
|
|
"""
|
|
if isinstance(style_item, EventHandler):
|
|
raise ReflexError(
|
|
"EventHandlers cannot be used as style values. "
|
|
"Please use a Var or a literal value."
|
|
)
|
|
|
|
if isinstance(style_item, Var):
|
|
return style_item, style_item._get_all_var_data()
|
|
|
|
# Otherwise, convert to Var to collapse VarData encoded in f-string.
|
|
new_var = LiteralVar.create(style_item)
|
|
var_data = new_var._get_all_var_data() if new_var is not None else None
|
|
return new_var, var_data
|
|
|
|
|
|
def convert_list(
|
|
responsive_list: list[str | dict | Var],
|
|
) -> tuple[list[str | dict[str, Var | list | 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: dict[str, Var | dict | list | str],
|
|
) -> tuple[dict[str, str | list | dict], VarData | None]:
|
|
"""Format a style dictionary.
|
|
|
|
Args:
|
|
style_dict: The style dictionary to format.
|
|
|
|
Returns:
|
|
The formatted style dictionary.
|
|
"""
|
|
var_data = None # Track import/hook data from any Vars in the style dict.
|
|
out = {}
|
|
|
|
def update_out_dict(
|
|
return_value: Var | dict | list | str, keys_to_update: tuple[str, ...]
|
|
):
|
|
for k in keys_to_update:
|
|
out[k] = return_value
|
|
|
|
for key, value in style_dict.items():
|
|
keys = (
|
|
format_style_key(key)
|
|
if not isinstance(value, (dict, ObjectVar, list))
|
|
or (
|
|
isinstance(value, Breakpoints)
|
|
and all(not isinstance(v, dict) for v in value.values())
|
|
)
|
|
or (isinstance(value, list) and all(not isinstance(v, dict) for v in value))
|
|
or (
|
|
isinstance(value, ObjectVar)
|
|
and not issubclass(get_origin(value._var_type) or value._var_type, dict)
|
|
)
|
|
else (key,)
|
|
)
|
|
|
|
if isinstance(value, Var):
|
|
return_val = value
|
|
new_var_data = value._get_all_var_data()
|
|
update_out_dict(return_val, keys)
|
|
elif isinstance(value, dict):
|
|
# Recursively format nested style dictionaries.
|
|
return_val, new_var_data = convert(value)
|
|
update_out_dict(return_val, keys)
|
|
elif isinstance(value, list):
|
|
# Responsive value is a list of dict or value
|
|
return_val, new_var_data = convert_list(value)
|
|
update_out_dict(return_val, keys)
|
|
else:
|
|
return_val, new_var_data = convert_item(value)
|
|
update_out_dict(return_val, keys)
|
|
# Combine all the collected VarData instances.
|
|
var_data = VarData.merge(var_data, new_var_data)
|
|
|
|
if isinstance(style_dict, Breakpoints):
|
|
out = Breakpoints(out).factorize()
|
|
|
|
return out, var_data
|
|
|
|
|
|
def format_style_key(key: str) -> Tuple[str, ...]:
|
|
"""Convert style keys to camel case and convert shorthand
|
|
styles names to their corresponding css names.
|
|
|
|
Args:
|
|
key: The style key to convert.
|
|
|
|
Returns:
|
|
Tuple of css style names corresponding to the key provided.
|
|
"""
|
|
if key.startswith("--"):
|
|
return (key,)
|
|
key = format.to_camel_case(key)
|
|
return STYLE_PROP_SHORTHAND_MAPPING.get(key, (key,))
|
|
|
|
|
|
class Style(dict):
|
|
"""A style dictionary."""
|
|
|
|
def __init__(self, style_dict: dict | None = None, **kwargs):
|
|
"""Initialize the style.
|
|
|
|
Args:
|
|
style_dict: The style dictionary.
|
|
kwargs: Other key value pairs to apply to the dict update.
|
|
"""
|
|
if style_dict:
|
|
style_dict.update(kwargs)
|
|
else:
|
|
style_dict = kwargs
|
|
style_dict, self._var_data = convert(style_dict or {})
|
|
super().__init__(style_dict)
|
|
|
|
def update(self, style_dict: dict | None, **kwargs):
|
|
"""Update the style.
|
|
|
|
Args:
|
|
style_dict: The style dictionary.
|
|
kwargs: Other key value pairs to apply to the dict update.
|
|
"""
|
|
if not isinstance(style_dict, Style):
|
|
converted_dict = type(self)(style_dict)
|
|
else:
|
|
converted_dict = style_dict
|
|
if kwargs:
|
|
if converted_dict is None:
|
|
converted_dict = type(self)(kwargs)
|
|
else:
|
|
converted_dict.update(kwargs)
|
|
# Combine our VarData with that of any Vars in the style_dict that was passed.
|
|
self._var_data = VarData.merge(self._var_data, converted_dict._var_data)
|
|
super().update(converted_dict)
|
|
|
|
def __setitem__(self, key: str, value: Any):
|
|
"""Set an item in the style.
|
|
|
|
Args:
|
|
key: The key to set.
|
|
value: The value to set.
|
|
"""
|
|
# Create a Var to collapse VarData encoded in f-string.
|
|
_var = LiteralVar.create(value)
|
|
if _var is not None:
|
|
# Carry the imports/hooks when setting a Var as a value.
|
|
self._var_data = VarData.merge(
|
|
getattr(self, "_var_data", None), _var._get_all_var_data()
|
|
)
|
|
super().__setitem__(key, value)
|
|
|
|
def __or__(self, other: Style | dict) -> Style:
|
|
"""Combine two styles.
|
|
|
|
Args:
|
|
other: The other style to combine.
|
|
|
|
Returns:
|
|
The combined style.
|
|
"""
|
|
other_var_data = None
|
|
if not isinstance(other, Style):
|
|
other_dict, other_var_data = convert(other)
|
|
else:
|
|
other_dict, other_var_data = other, other._var_data
|
|
|
|
new_style = Style(super().__or__(other_dict))
|
|
if self._var_data or other_var_data:
|
|
new_style._var_data = VarData.merge(self._var_data, other_var_data)
|
|
return new_style
|
|
|
|
|
|
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/:hover).
|
|
|
|
Returns:
|
|
A self-referential pseudo selector key (&:hover).
|
|
"""
|
|
prefix = None
|
|
if key.startswith("_"):
|
|
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]) -> Style | None:
|
|
"""Convert the style to an emotion-compatible CSS-in-JS dict.
|
|
|
|
Args:
|
|
style_dict: The style dict to convert.
|
|
|
|
Returns:
|
|
The emotion style dict.
|
|
"""
|
|
_var_data = style_dict._var_data if isinstance(style_dict, Style) else None
|
|
|
|
emotion_style = Style()
|
|
|
|
for orig_key, value in style_dict.items():
|
|
key = _format_emotion_style_pseudo_selector(orig_key)
|
|
if isinstance(value, (Breakpoints, list)):
|
|
if isinstance(value, Breakpoints):
|
|
mbps = {
|
|
media_query(bp): (
|
|
bp_value if isinstance(bp_value, dict) else {key: bp_value}
|
|
)
|
|
for bp, bp_value in value.items()
|
|
}
|
|
else:
|
|
# Apply media queries from responsive value list.
|
|
mbps = {
|
|
media_query([0, *breakpoints_values][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:
|
|
if _var_data is not None:
|
|
emotion_style._var_data = VarData.merge(emotion_style._var_data, _var_data)
|
|
return emotion_style
|
|
|
|
|
|
def convert_dict_to_style_and_format_emotion(
|
|
raw_dict: dict[str, Any],
|
|
) -> dict[str, Any] | None:
|
|
"""Convert a dict to a style dict and then format as emotion.
|
|
|
|
Args:
|
|
raw_dict: The dict to convert.
|
|
|
|
Returns:
|
|
The emotion dict.
|
|
|
|
"""
|
|
return format_as_emotion(Style(raw_dict))
|
|
|
|
|
|
STACK_CHILDREN_FULL_WIDTH = {
|
|
"& :where(.rx-Stack)": {
|
|
"width": "100%",
|
|
},
|
|
"& :where(.rx-Stack) > :where( "
|
|
"div:not(.rt-Box, .rx-Upload, .rx-Html),"
|
|
"input, select, textarea, table"
|
|
")": {
|
|
"width": "100%",
|
|
"flex_shrink": "1",
|
|
},
|
|
}
|