style.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. """Handle styling."""
  2. from __future__ import annotations
  3. from typing import Any
  4. from reflex import constants
  5. from reflex.event import EventChain
  6. from reflex.utils import format
  7. from reflex.utils.imports import ImportVar
  8. from reflex.vars import BaseVar, Var, VarData
  9. VarData.update_forward_refs() # Ensure all type definitions are resolved
  10. # Reference the global ColorModeContext
  11. color_mode_var_data = VarData( # type: ignore
  12. imports={
  13. f"/{constants.Dirs.CONTEXTS_PATH}": {ImportVar(tag="ColorModeContext")},
  14. "react": {ImportVar(tag="useContext")},
  15. },
  16. hooks={
  17. f"const [ {constants.ColorMode.NAME}, {constants.ColorMode.TOGGLE} ] = useContext(ColorModeContext)",
  18. },
  19. )
  20. # Var resolves to the current color mode for the app ("light" or "dark")
  21. color_mode = BaseVar(
  22. _var_name=constants.ColorMode.NAME,
  23. _var_type="str",
  24. _var_data=color_mode_var_data,
  25. )
  26. # Var resolves to a function invocation that toggles the color mode
  27. toggle_color_mode = BaseVar(
  28. _var_name=constants.ColorMode.TOGGLE,
  29. _var_type=EventChain,
  30. _var_data=color_mode_var_data,
  31. )
  32. breakpoints = ["0", "30em", "48em", "62em", "80em", "96em"]
  33. def media_query(breakpoint_index: int):
  34. """Create a media query selector.
  35. Args:
  36. breakpoint_index: The index of the breakpoint to use.
  37. Returns:
  38. The media query selector used as a key in emotion css dict.
  39. """
  40. return f"@media screen and (min-width: {breakpoints[breakpoint_index]})"
  41. def convert_item(style_item: str | Var) -> tuple[str, VarData | None]:
  42. """Format a single value in a style dictionary.
  43. Args:
  44. style_item: The style item to format.
  45. Returns:
  46. The formatted style item and any associated VarData.
  47. """
  48. if isinstance(style_item, Var):
  49. # If the value is a Var, extract the var_data and cast as str.
  50. return str(style_item), style_item._var_data
  51. # Otherwise, convert to Var to collapse VarData encoded in f-string.
  52. new_var = Var.create(style_item)
  53. if new_var is not None and new_var._var_data:
  54. # The wrapped backtick is used to identify the Var for interpolation.
  55. return f"`{str(new_var)}`", new_var._var_data
  56. return style_item, None
  57. def convert_list(
  58. responsive_list: list[str | dict | Var],
  59. ) -> tuple[list[str | dict], VarData | None]:
  60. """Format a responsive value list.
  61. Args:
  62. responsive_list: The raw responsive value list (one value per breakpoint).
  63. Returns:
  64. The recursively converted responsive value list and any associated VarData.
  65. """
  66. converted_value = []
  67. item_var_datas = []
  68. for responsive_item in responsive_list:
  69. if isinstance(responsive_item, dict):
  70. # Recursively format nested style dictionaries.
  71. item, item_var_data = convert(responsive_item)
  72. else:
  73. item, item_var_data = convert_item(responsive_item)
  74. converted_value.append(item)
  75. item_var_datas.append(item_var_data)
  76. return converted_value, VarData.merge(*item_var_datas)
  77. def convert(style_dict):
  78. """Format a style dictionary.
  79. Args:
  80. style_dict: The style dictionary to format.
  81. Returns:
  82. The formatted style dictionary.
  83. """
  84. var_data = None # Track import/hook data from any Vars in the style dict.
  85. out = {}
  86. for key, value in style_dict.items():
  87. key = format.to_camel_case(key)
  88. if isinstance(value, dict):
  89. # Recursively format nested style dictionaries.
  90. out[key], new_var_data = convert(value)
  91. elif isinstance(value, list):
  92. # Responsive value is a list of dict or value
  93. out[key], new_var_data = convert_list(value)
  94. else:
  95. out[key], new_var_data = convert_item(value)
  96. # Combine all the collected VarData instances.
  97. var_data = VarData.merge(var_data, new_var_data)
  98. return out, var_data
  99. class Style(dict):
  100. """A style dictionary."""
  101. def __init__(self, style_dict: dict | None = None):
  102. """Initialize the style.
  103. Args:
  104. style_dict: The style dictionary.
  105. """
  106. style_dict, self._var_data = convert(style_dict or {})
  107. super().__init__(style_dict)
  108. def update(self, style_dict: dict | None, **kwargs):
  109. """Update the style.
  110. Args:
  111. style_dict: The style dictionary.
  112. kwargs: Other key value pairs to apply to the dict update.
  113. """
  114. if kwargs:
  115. style_dict = {**(style_dict or {}), **kwargs}
  116. if not isinstance(style_dict, Style):
  117. converted_dict = type(self)(style_dict)
  118. else:
  119. converted_dict = style_dict
  120. # Combine our VarData with that of any Vars in the style_dict that was passed.
  121. self._var_data = VarData.merge(self._var_data, converted_dict._var_data)
  122. super().update(converted_dict)
  123. def __setitem__(self, key: str, value: Any):
  124. """Set an item in the style.
  125. Args:
  126. key: The key to set.
  127. value: The value to set.
  128. """
  129. # Create a Var to collapse VarData encoded in f-string.
  130. _var = Var.create(value)
  131. if _var is not None:
  132. # Carry the imports/hooks when setting a Var as a value.
  133. self._var_data = VarData.merge(self._var_data, _var._var_data)
  134. super().__setitem__(key, value)
  135. def _format_emotion_style_pseudo_selector(key: str) -> str:
  136. """Format a pseudo selector for emotion CSS-in-JS.
  137. Args:
  138. key: Underscore-prefixed or colon-prefixed pseudo selector key (_hover).
  139. Returns:
  140. A self-referential pseudo selector key (&:hover).
  141. """
  142. prefix = None
  143. if key.startswith("_"):
  144. # Handle pseudo selectors in chakra style format.
  145. prefix = "&:"
  146. key = key[1:]
  147. if key.startswith(":"):
  148. # Handle pseudo selectors and elements in native format.
  149. prefix = "&"
  150. if prefix is not None:
  151. return prefix + format.to_kebab_case(key)
  152. return key
  153. def format_as_emotion(style_dict: dict[str, Any]) -> dict[str, Any] | None:
  154. """Convert the style to an emotion-compatible CSS-in-JS dict.
  155. Args:
  156. style_dict: The style dict to convert.
  157. Returns:
  158. The emotion dict.
  159. """
  160. emotion_style = {}
  161. for orig_key, value in style_dict.items():
  162. key = _format_emotion_style_pseudo_selector(orig_key)
  163. if isinstance(value, list):
  164. # Apply media queries from responsive value list.
  165. mbps = {
  166. media_query(bp): bp_value
  167. if isinstance(bp_value, dict)
  168. else {key: bp_value}
  169. for bp, bp_value in enumerate(value)
  170. }
  171. if key.startswith("&:"):
  172. emotion_style[key] = mbps
  173. else:
  174. for mq, style_sub_dict in mbps.items():
  175. emotion_style.setdefault(mq, {}).update(style_sub_dict)
  176. elif isinstance(value, dict):
  177. # Recursively format nested style dictionaries.
  178. emotion_style[key] = format_as_emotion(value)
  179. else:
  180. emotion_style[key] = value
  181. if emotion_style:
  182. return emotion_style