accordion.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. """Radix accordion components."""
  2. from __future__ import annotations
  3. from typing import Any, List, Literal, Optional, Union
  4. from reflex.components.component import Component, ComponentNamespace
  5. from reflex.components.core.colors import color
  6. from reflex.components.core.cond import cond
  7. from reflex.components.lucide.icon import Icon
  8. from reflex.components.radix.primitives.base import RadixPrimitiveComponent
  9. from reflex.components.radix.themes.base import LiteralAccentColor, LiteralRadius
  10. from reflex.event import EventHandler
  11. from reflex.ivars.base import ImmutableVar, LiteralVar
  12. from reflex.style import Style
  13. from reflex.vars import Var, get_uuid_string_var
  14. LiteralAccordionType = Literal["single", "multiple"]
  15. LiteralAccordionDir = Literal["ltr", "rtl"]
  16. LiteralAccordionOrientation = Literal["vertical", "horizontal"]
  17. LiteralAccordionVariant = Literal["classic", "soft", "surface", "outline", "ghost"]
  18. DEFAULT_ANIMATION_DURATION = 250
  19. DEFAULT_ANIMATION_EASING = "cubic-bezier(0.87, 0, 0.13, 1)"
  20. def _inherited_variant_selector(
  21. variant: Var[LiteralAccordionVariant] | LiteralAccordionVariant,
  22. *selectors: str,
  23. ) -> str:
  24. """Create a multi CSS selector for targeting variant against the given selectors.
  25. Args:
  26. variant: The variant to target.
  27. selectors: The selectors to apply the variant to (default &)
  28. Returns:
  29. A CSS selector that is more specific on elements that directly set the variant.
  30. """
  31. if not selectors:
  32. selectors = ("&",)
  33. # Prefer the `data-variant` that is set directly on the selector,
  34. # but also inherit the `data-variant` from any parent element.
  35. return ", ".join(
  36. [
  37. f"{selector}[data-variant='{variant}'], *:where([data-variant='{variant}']) {selector}"
  38. for selector in selectors
  39. ]
  40. )
  41. class AccordionComponent(RadixPrimitiveComponent):
  42. """Base class for all @radix-ui/accordion components."""
  43. library = "@radix-ui/react-accordion@^1.1.2"
  44. # The color scheme of the component.
  45. color_scheme: Var[LiteralAccentColor]
  46. # The variant of the component.
  47. variant: Var[LiteralAccordionVariant]
  48. def add_style(self):
  49. """Add style to the component."""
  50. if self.color_scheme is not None:
  51. self.custom_attrs["data-accent-color"] = self.color_scheme
  52. if self.variant is not None:
  53. self.custom_attrs["data-variant"] = self.variant
  54. def _exclude_props(self) -> list[str]:
  55. return ["color_scheme", "variant"]
  56. class AccordionRoot(AccordionComponent):
  57. """An accordion component."""
  58. tag = "Root"
  59. alias = "RadixAccordionRoot"
  60. # The type of accordion (single or multiple).
  61. type: Var[LiteralAccordionType]
  62. # The value of the item to expand.
  63. value: Var[Union[str, List[str]]]
  64. # The default value of the item to expand.
  65. default_value: Var[Union[str, List[str]]]
  66. # Whether or not the accordion is collapsible.
  67. collapsible: Var[bool]
  68. # Whether or not the accordion is disabled.
  69. disabled: Var[bool]
  70. # The reading direction of the accordion when applicable.
  71. dir: Var[LiteralAccordionDir]
  72. # The orientation of the accordion.
  73. orientation: Var[LiteralAccordionOrientation]
  74. # The radius of the accordion corners.
  75. radius: Var[LiteralRadius]
  76. # The time in milliseconds to animate open and close
  77. duration: Var[int] = LiteralVar.create(DEFAULT_ANIMATION_DURATION)
  78. # The easing function to use for the animation.
  79. easing: Var[str] = LiteralVar.create(DEFAULT_ANIMATION_EASING)
  80. # Whether to show divider lines between items.
  81. show_dividers: Var[bool]
  82. _valid_children: List[str] = ["AccordionItem"]
  83. # Fired when the opened the accordions changes.
  84. on_value_change: EventHandler[lambda e0: [e0]]
  85. def _exclude_props(self) -> list[str]:
  86. return super()._exclude_props() + [
  87. "radius",
  88. "duration",
  89. "easing",
  90. "show_dividers",
  91. ]
  92. def add_style(self):
  93. """Add style to the component.
  94. Returns:
  95. The style of the component.
  96. """
  97. if self.radius is not None:
  98. self.custom_attrs["data-radius"] = self.radius
  99. if self.variant is None:
  100. # The default variant is classic
  101. self.custom_attrs["data-variant"] = "classic"
  102. style = {
  103. "border_radius": "var(--radius-4)",
  104. "box_shadow": f"0 2px 10px {color('black', 1, alpha=True)}",
  105. "&[data-variant='classic']": {
  106. "background_color": color("accent", 9),
  107. "box_shadow": f"0 2px 10px {color('black', 4, alpha=True)}",
  108. },
  109. "&[data-variant='soft']": {
  110. "background_color": color("accent", 3),
  111. },
  112. "&[data-variant='outline']": {
  113. "border": f"1px solid {color('accent', 6)}",
  114. },
  115. "&[data-variant='surface']": {
  116. "border": f"1px solid {color('accent', 6)}",
  117. "background_color": "var(--accent-surface)",
  118. },
  119. "&[data-variant='ghost']": {
  120. "background_color": "none",
  121. "box_shadow": "None",
  122. },
  123. "--animation-duration": f"{self.duration}ms",
  124. "--animation-easing": self.easing,
  125. }
  126. if self.show_dividers is not None:
  127. style["--divider-px"] = cond(self.show_dividers, "1px", "0")
  128. else:
  129. style["&[data-variant='outline']"]["--divider-px"] = "1px"
  130. style["&[data-variant='surface']"]["--divider-px"] = "1px"
  131. return Style(style)
  132. class AccordionItem(AccordionComponent):
  133. """An accordion component."""
  134. tag = "Item"
  135. alias = "RadixAccordionItem"
  136. # A unique identifier for the item.
  137. value: Var[str]
  138. # When true, prevents the user from interacting with the item.
  139. disabled: Var[bool]
  140. _valid_children: List[str] = [
  141. "AccordionHeader",
  142. "AccordionTrigger",
  143. "AccordionContent",
  144. ]
  145. _valid_parents: List[str] = ["AccordionRoot"]
  146. @classmethod
  147. def create(
  148. cls,
  149. *children,
  150. header: Optional[Component | ImmutableVar] = None,
  151. content: Optional[Component | ImmutableVar] = None,
  152. **props,
  153. ) -> Component:
  154. """Create an accordion item.
  155. Args:
  156. *children: The list of children to use if header and content are not provided.
  157. header: The header of the accordion item.
  158. content: The content of the accordion item.
  159. **props: Additional properties to apply to the accordion item.
  160. Returns:
  161. The accordion item.
  162. """
  163. # The item requires a value to toggle (use a random unique name if not provided).
  164. value = props.pop("value", get_uuid_string_var())
  165. if "AccordionItem" not in (
  166. cls_name := props.pop("class_name", "AccordionItem")
  167. ):
  168. cls_name = f"{cls_name} AccordionItem"
  169. color_scheme = props.get("color_scheme")
  170. variant = props.get("variant")
  171. if (header is not None) and (content is not None):
  172. children = [
  173. AccordionHeader.create(
  174. AccordionTrigger.create(
  175. header,
  176. AccordionIcon.create(
  177. color_scheme=color_scheme,
  178. variant=variant,
  179. ),
  180. color_scheme=color_scheme,
  181. variant=variant,
  182. ),
  183. color_scheme=color_scheme,
  184. variant=variant,
  185. ),
  186. AccordionContent.create(
  187. content,
  188. color_scheme=color_scheme,
  189. variant=variant,
  190. ),
  191. ]
  192. return super().create(*children, value=value, **props, class_name=cls_name)
  193. def add_style(self) -> dict[str, Any] | None:
  194. """Add style to the component.
  195. Returns:
  196. The style of the component.
  197. """
  198. divider_style = f"var(--divider-px) solid {color('gray', 6, alpha=True)}"
  199. return {
  200. "overflow": "hidden",
  201. "width": "100%",
  202. "margin_top": "1px",
  203. "border_top": divider_style,
  204. "&:first-child": {
  205. "margin_top": 0,
  206. "border_top": 0,
  207. "border_top_left_radius": "var(--radius-4)",
  208. "border_top_right_radius": "var(--radius-4)",
  209. },
  210. "&:last-child": {
  211. "border_bottom_left_radius": "var(--radius-4)",
  212. "border_bottom_right_radius": "var(--radius-4)",
  213. },
  214. "&:focus-within": {
  215. "position": "relative",
  216. "z_index": 1,
  217. },
  218. _inherited_variant_selector("ghost", "&:first-child"): {
  219. "border_radius": 0,
  220. "border_top": divider_style,
  221. },
  222. _inherited_variant_selector("ghost", "&:last-child"): {
  223. "border_radius": 0,
  224. "border_bottom": divider_style,
  225. },
  226. }
  227. class AccordionHeader(AccordionComponent):
  228. """An accordion component."""
  229. tag = "Header"
  230. alias = "RadixAccordionHeader"
  231. @classmethod
  232. def create(cls, *children, **props) -> Component:
  233. """Create the Accordion header component.
  234. Args:
  235. *children: The children of the component.
  236. **props: The properties of the component.
  237. Returns:
  238. The Accordion header Component.
  239. """
  240. if "AccordionHeader" not in (
  241. cls_name := props.pop("class_name", "AccordionHeader")
  242. ):
  243. cls_name = f"{cls_name} AccordionHeader"
  244. return super().create(*children, class_name=cls_name, **props)
  245. def add_style(self) -> dict[str, Any] | None:
  246. """Add style to the component.
  247. Returns:
  248. The style of the component.
  249. """
  250. return {"display": "flex"}
  251. class AccordionTrigger(AccordionComponent):
  252. """An accordion component."""
  253. tag = "Trigger"
  254. alias = "RadixAccordionTrigger"
  255. @classmethod
  256. def create(cls, *children, **props) -> Component:
  257. """Create the Accordion trigger component.
  258. Args:
  259. *children: The children of the component.
  260. **props: The properties of the component.
  261. Returns:
  262. The Accordion trigger Component.
  263. """
  264. if "AccordionTrigger" not in (
  265. cls_name := props.pop("class_name", "AccordionTrigger")
  266. ):
  267. cls_name = f"{cls_name} AccordionTrigger"
  268. return super().create(*children, class_name=cls_name, **props)
  269. def add_style(self) -> dict[str, Any] | None:
  270. """Add style to the component.
  271. Returns:
  272. The style of the component.
  273. """
  274. return {
  275. "color": color("accent", 11),
  276. "font_size": "1.1em",
  277. "line_height": 1,
  278. "justify_content": "space-between",
  279. "align_items": "center",
  280. "flex": 1,
  281. "display": "flex",
  282. "padding": "var(--space-3) var(--space-4)",
  283. "width": "100%",
  284. "box_shadow": f"0 var(--divider-px) 0 {color('gray', 6, alpha=True)}",
  285. "&[data-state='open'] > .AccordionChevron": {
  286. "transform": "rotate(180deg)",
  287. },
  288. "&:hover": {
  289. "background_color": color("accent", 4),
  290. },
  291. "& > .AccordionChevron": {
  292. "transition": f"transform var(--animation-duration) var(--animation-easing)",
  293. },
  294. _inherited_variant_selector("classic"): {
  295. "color": "var(--accent-contrast)",
  296. "&:hover": {
  297. "background_color": color("accent", 10),
  298. },
  299. "& > .AccordionChevron": {
  300. "color": "var(--accent-contrast)",
  301. },
  302. },
  303. }
  304. class AccordionIcon(Icon):
  305. """An accordion icon component."""
  306. @classmethod
  307. def create(cls, *children, **props) -> Component:
  308. """Create the Accordion icon component.
  309. Args:
  310. *children: The children of the component.
  311. **props: The properties of the component.
  312. Returns:
  313. The Accordion icon Component.
  314. """
  315. if "AccordionChevron" not in (
  316. cls_name := props.pop("class_name", "AccordionChevron")
  317. ):
  318. cls_name = f"{cls_name} AccordionChevron"
  319. return super().create(tag="chevron_down", class_name=cls_name, **props)
  320. class AccordionContent(AccordionComponent):
  321. """An accordion component."""
  322. tag = "Content"
  323. alias = "RadixAccordionContent"
  324. def add_imports(self) -> dict:
  325. """Add imports to the component.
  326. Returns:
  327. The imports of the component.
  328. """
  329. return {"@emotion/react": "keyframes"}
  330. @classmethod
  331. def create(cls, *children, **props) -> Component:
  332. """Create the Accordion content component.
  333. Args:
  334. *children: The children of the component.
  335. **props: The properties of the component.
  336. Returns:
  337. The Accordion content Component.
  338. """
  339. if "AccordionContent" not in (
  340. cls_name := props.pop("class_name", "AccordionContent")
  341. ):
  342. cls_name = f"{cls_name} AccordionContent"
  343. return super().create(*children, class_name=cls_name, **props)
  344. def add_custom_code(self) -> list[str]:
  345. """Add custom code to the component.
  346. Returns:
  347. The custom code of the component.
  348. """
  349. return [
  350. """
  351. const slideDown = keyframes`
  352. from {
  353. height: 0;
  354. }
  355. to {
  356. height: var(--radix-accordion-content-height);
  357. }
  358. `
  359. const slideUp = keyframes`
  360. from {
  361. height: var(--radix-accordion-content-height);
  362. }
  363. to {
  364. height: 0;
  365. }
  366. `
  367. """
  368. ]
  369. def add_style(self) -> dict[str, Any] | None:
  370. """Add style to the component.
  371. Returns:
  372. The style of the component.
  373. """
  374. slideDown = LiteralVar.create(
  375. f"${{slideDown}} var(--animation-duration) var(--animation-easing)",
  376. )
  377. slideUp = LiteralVar.create(
  378. f"${{slideUp}} var(--animation-duration) var(--animation-easing)",
  379. )
  380. return {
  381. "overflow": "hidden",
  382. "color": color("accent", 11),
  383. "padding_x": "var(--space-4)",
  384. # Apply before and after content to avoid height animation jank.
  385. "&:before, &:after": {
  386. "content": "' '",
  387. "display": "block",
  388. "height": "var(--space-3)",
  389. },
  390. "&[data-state='open']": {"animation": slideDown},
  391. "&[data-state='closed']": {"animation": slideUp},
  392. _inherited_variant_selector("classic"): {
  393. "color": "var(--accent-contrast)",
  394. },
  395. }
  396. class Accordion(ComponentNamespace):
  397. """Accordion component."""
  398. content = staticmethod(AccordionContent.create)
  399. header = staticmethod(AccordionHeader.create)
  400. item = staticmethod(AccordionItem.create)
  401. icon = staticmethod(AccordionIcon.create)
  402. root = staticmethod(AccordionRoot.create)
  403. trigger = staticmethod(AccordionTrigger.create)
  404. accordion = Accordion()