1
0

accordion.py 16 KB

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