accordion.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663
  1. """Radix accordion components."""
  2. from __future__ import annotations
  3. from types import SimpleNamespace
  4. from typing import Any, Dict, List, Literal, Optional, Union
  5. from reflex.components.component import Component
  6. from reflex.components.core.match import Match
  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
  10. from reflex.style import (
  11. Style,
  12. convert_dict_to_style_and_format_emotion,
  13. format_as_emotion,
  14. )
  15. from reflex.utils import imports
  16. from reflex.vars import BaseVar, Var, VarData, get_unique_variable_name
  17. LiteralAccordionType = Literal["single", "multiple"]
  18. LiteralAccordionDir = Literal["ltr", "rtl"]
  19. LiteralAccordionOrientation = Literal["vertical", "horizontal"]
  20. LiteralAccordionRootVariant = Literal["classic", "soft", "surface", "outline", "ghost"]
  21. LiteralAccordionRootColorScheme = Literal["primary", "accent"]
  22. DEFAULT_ANIMATION_DURATION = 250
  23. def get_theme_accordion_root(variant: Var[str], color_scheme: Var[str]) -> BaseVar:
  24. """Get the theme for the accordion root component.
  25. Args:
  26. variant: The variant of the accordion.
  27. color_scheme: The color of the accordion.
  28. Returns:
  29. The theme for the accordion root component.
  30. """
  31. return Match.create( # type: ignore
  32. variant,
  33. (
  34. "soft",
  35. convert_dict_to_style_and_format_emotion(
  36. {
  37. "border_radius": "6px",
  38. "background_color": f"var(--{color_scheme}-3)",
  39. "box_shadow": "0 2px 10px var(--black-a1)",
  40. }
  41. ),
  42. ),
  43. (
  44. "outline",
  45. convert_dict_to_style_and_format_emotion(
  46. {
  47. "border_radius": "6px",
  48. "border": f"1px solid var(--{color_scheme}-6)",
  49. "box_shadow": "0 2px 10px var(--black-a1)",
  50. }
  51. ),
  52. ),
  53. (
  54. "surface",
  55. convert_dict_to_style_and_format_emotion(
  56. {
  57. "border_radius": "6px",
  58. "border": f"1px solid var(--{color_scheme}-6)",
  59. "background_color": f"var(--{color_scheme}-3)",
  60. "box_shadow": "0 2px 10px var(--black-a1)",
  61. }
  62. ),
  63. ),
  64. (
  65. "ghost",
  66. convert_dict_to_style_and_format_emotion(
  67. {
  68. "border_radius": "6px",
  69. "background_color": "none",
  70. "box_shadow": "None",
  71. }
  72. ),
  73. ),
  74. convert_dict_to_style_and_format_emotion(
  75. {
  76. "border_radius": "6px",
  77. "background_color": f"var(--{color_scheme}-9)",
  78. "box_shadow": "0 2px 10px var(--black-a4)",
  79. }
  80. ),
  81. # defaults to classic
  82. )
  83. def get_theme_accordion_item():
  84. """Get the theme for the accordion item component.
  85. Returns:
  86. The theme for the accordion item component.
  87. """
  88. return convert_dict_to_style_and_format_emotion(
  89. {
  90. "overflow": "hidden",
  91. "width": "100%",
  92. "margin_top": "1px",
  93. "&:first-child": {
  94. "margin_top": 0,
  95. "border_top_left_radius": "4px",
  96. "border_top_right_radius": "4px",
  97. },
  98. "&:last-child": {
  99. "border_bottom_left_radius": "4px",
  100. "border_bottom_right_radius": "4px",
  101. },
  102. "&:focus-within": {
  103. "position": "relative",
  104. "z_index": 1,
  105. },
  106. }
  107. )
  108. def get_theme_accordion_header() -> dict[str, str]:
  109. """Get the theme for the accordion header component.
  110. Returns:
  111. The theme for the accordion header component.
  112. """
  113. return {
  114. "display": "flex",
  115. }
  116. def get_theme_accordion_trigger(variant: str | Var, color_scheme: str | Var) -> BaseVar:
  117. """Get the theme for the accordion trigger component.
  118. Args:
  119. variant: The variant of the accordion.
  120. color_scheme: The color of the accordion.
  121. Returns:
  122. The theme for the accordion trigger component.
  123. """
  124. return Match.create( # type: ignore
  125. variant,
  126. (
  127. "soft",
  128. convert_dict_to_style_and_format_emotion(
  129. {
  130. "color": f"var(--{color_scheme}-11)",
  131. "&:hover": {
  132. "background_color": f"var(--{color_scheme}-4)",
  133. },
  134. "& > .AccordionChevron": {
  135. "color": f"var(--{color_scheme}-11)",
  136. "transition": f"transform {DEFAULT_ANIMATION_DURATION}ms cubic-bezier(0.87, 0, 0.13, 1)",
  137. },
  138. "&[data-state='open'] > .AccordionChevron": {
  139. "transform": "rotate(180deg)",
  140. },
  141. "font_family": "inherit",
  142. "width": "100%",
  143. "padding": "0 20px",
  144. "height": "45px",
  145. "flex": 1,
  146. "display": "flex",
  147. "align_items": "center",
  148. "justify_content": "space-between",
  149. "font_size": "15px",
  150. "line_height": 1,
  151. }
  152. ),
  153. ),
  154. (
  155. "outline",
  156. "surface",
  157. "ghost",
  158. convert_dict_to_style_and_format_emotion(
  159. {
  160. "color": f"var(--{color_scheme}-11)",
  161. "&:hover": {
  162. "background_color": f"var(--{color_scheme}-4)",
  163. },
  164. "& > .AccordionChevron": {
  165. "color": f"var(--{color_scheme}-11)",
  166. "transition": f"transform {DEFAULT_ANIMATION_DURATION}ms cubic-bezier(0.87, 0, 0.13, 1)",
  167. },
  168. "&[data-state='open'] > .AccordionChevron": {
  169. "transform": "rotate(180deg)",
  170. },
  171. "font_family": "inherit",
  172. "width": "100%",
  173. "padding": "0 20px",
  174. "height": "45px",
  175. "flex": 1,
  176. "display": "flex",
  177. "align_items": "center",
  178. "justify_content": "space-between",
  179. "font_size": "15px",
  180. "line_height": 1,
  181. }
  182. ),
  183. ),
  184. # defaults to classic
  185. convert_dict_to_style_and_format_emotion(
  186. {
  187. "color": f"var(--{color_scheme}-9-contrast)",
  188. "box_shadow": f"var(--{color_scheme}-11)",
  189. "&:hover": {
  190. "background_color": f"var(--{color_scheme}-10)",
  191. },
  192. "& > .AccordionChevron": {
  193. "color": f"var(--{color_scheme}-9-contrast)",
  194. "transition": f"transform {DEFAULT_ANIMATION_DURATION}ms cubic-bezier(0.87, 0, 0.13, 1)",
  195. },
  196. "&[data-state='open'] > .AccordionChevron": {
  197. "transform": "rotate(180deg)",
  198. },
  199. "font_family": "inherit",
  200. "width": "100%",
  201. "padding": "0 20px",
  202. "height": "45px",
  203. "flex": 1,
  204. "display": "flex",
  205. "align_items": "center",
  206. "justify_content": "space-between",
  207. "font_size": "15px",
  208. "line_height": 1,
  209. }
  210. ),
  211. )
  212. def get_theme_accordion_content(variant: str | Var, color_scheme: str | Var) -> BaseVar:
  213. """Get the theme for the accordion content component.
  214. Args:
  215. variant: The variant of the accordion.
  216. color_scheme: The color of the accordion.
  217. Returns:
  218. The theme for the accordion content component.
  219. """
  220. return Match.create( # type: ignore
  221. variant,
  222. (
  223. "outline",
  224. "ghost",
  225. convert_dict_to_style_and_format_emotion(
  226. {
  227. "overflow": "hidden",
  228. "font_size": "10px",
  229. "color": f"var(--{color_scheme}-11)",
  230. "padding": "15px, 20px",
  231. "&[data-state='open']": {
  232. "animation": Var.create(
  233. f"${{slideDown}} {DEFAULT_ANIMATION_DURATION}ms cubic-bezier(0.87, 0, 0.13, 1)",
  234. _var_is_string=True,
  235. ),
  236. },
  237. "&[data-state='closed']": {
  238. "animation": Var.create(
  239. f"${{slideUp}} {DEFAULT_ANIMATION_DURATION}ms cubic-bezier(0.87, 0, 0.13, 1)",
  240. _var_is_string=True,
  241. ),
  242. },
  243. }
  244. ),
  245. ),
  246. convert_dict_to_style_and_format_emotion(
  247. {
  248. "overflow": "hidden",
  249. "font_size": "10px",
  250. "color": Match.create(
  251. variant,
  252. ("classic", f"var(--{color_scheme}-9-contrast)"),
  253. f"var(--{color_scheme}-11)",
  254. ),
  255. "background_color": Match.create(
  256. variant,
  257. ("classic", f"var(--{color_scheme}-9)"),
  258. f"var(--{color_scheme}-3)",
  259. ),
  260. "padding": "15px, 20px",
  261. "&[data-state='open']": {
  262. "animation": Var.create(
  263. f"${{slideDown}} {DEFAULT_ANIMATION_DURATION}ms cubic-bezier(0.87, 0, 0.13, 1)",
  264. _var_is_string=True,
  265. ),
  266. },
  267. "&[data-state='closed']": {
  268. "animation": Var.create(
  269. f"${{slideUp}} {DEFAULT_ANIMATION_DURATION}ms cubic-bezier(0.87, 0, 0.13, 1)",
  270. _var_is_string=True,
  271. ),
  272. },
  273. }
  274. ),
  275. )
  276. class AccordionComponent(RadixPrimitiveComponent):
  277. """Base class for all @radix-ui/accordion components."""
  278. library = "@radix-ui/react-accordion@^1.1.2"
  279. class AccordionRoot(AccordionComponent):
  280. """An accordion component."""
  281. tag = "Root"
  282. alias = "RadixAccordionRoot"
  283. # The type of accordion (single or multiple).
  284. type_: Var[LiteralAccordionType]
  285. # The value of the item to expand.
  286. value: Var[Optional[Union[str, List[str]]]]
  287. # The default value of the item to expand.
  288. default_value: Var[Optional[Union[str, List[str]]]]
  289. # Whether or not the accordion is collapsible.
  290. collapsible: Var[bool]
  291. # Whether or not the accordion is disabled.
  292. disabled: Var[bool]
  293. # The reading direction of the accordion when applicable.
  294. dir: Var[LiteralAccordionDir]
  295. # The orientation of the accordion.
  296. orientation: Var[LiteralAccordionOrientation]
  297. # The variant of the accordion.
  298. variant: Var[LiteralAccordionRootVariant] = "classic" # type: ignore
  299. # The color scheme of the accordion.
  300. color_scheme: Var[LiteralAccentColor] # type: ignore
  301. # dynamic themes of the accordion generated at compile time.
  302. _dynamic_themes: Var[dict] = Var.create({}) # type: ignore
  303. # The var_data associated with the component.
  304. _var_data: VarData = VarData() # type: ignore
  305. _valid_children: List[str] = ["AccordionItem"]
  306. @classmethod
  307. def create(cls, *children, **props) -> Component:
  308. """Create the Accordion root component.
  309. Args:
  310. *children: The children of the component.
  311. **props: The properties of the component.
  312. Returns:
  313. The Accordion root Component.
  314. """
  315. comp = super().create(*children, **props)
  316. if comp.color_scheme is not None and not comp.color_scheme._var_state: # type: ignore
  317. # mark the vars of color string literals as strings so they can be formatted properly when performing a var operation.
  318. comp.color_scheme._var_is_string = True # type: ignore
  319. if comp.variant is not None and not comp.variant._var_state: # type: ignore
  320. # mark the vars of variant string literals as strings so they are formatted properly in the match condition.
  321. comp.variant._var_is_string = True # type: ignore
  322. return comp
  323. def _get_style(self) -> dict:
  324. """Get the style for the component.
  325. Returns:
  326. The dictionary of the component style as value and the style notation as key.
  327. """
  328. return {"css": self._dynamic_themes._merge(format_as_emotion(self.style))} # type: ignore
  329. def _apply_theme(self, theme: Component):
  330. global_color_scheme = getattr(theme, "accent_color", None)
  331. if global_color_scheme is None and self.color_scheme is None:
  332. raise ValueError(
  333. "`color_scheme` cannot be None. Either set the `color_scheme` prop on the accordion "
  334. "component or set the `accent_color` prop in your global theme."
  335. )
  336. # prepare the color_scheme var to be used in an f-string(strip off the wrapping curly brace)
  337. color_scheme = Var.create(
  338. self.color_scheme if self.color_scheme is not None else global_color_scheme
  339. )._replace( # type: ignore
  340. _var_is_string=False
  341. )
  342. accordion_theme_root = get_theme_accordion_root(
  343. variant=self.variant, color_scheme=color_scheme
  344. )
  345. accordion_theme_content = get_theme_accordion_content(
  346. variant=self.variant, color_scheme=color_scheme
  347. )
  348. accordion_theme_trigger = get_theme_accordion_trigger(
  349. variant=self.variant, color_scheme=color_scheme
  350. )
  351. # extract var_data from dynamic themes.
  352. self._var_data = (
  353. self._var_data.merge( # type: ignore
  354. accordion_theme_trigger._var_data,
  355. accordion_theme_content._var_data,
  356. accordion_theme_root._var_data,
  357. )
  358. or self._var_data
  359. )
  360. self._dynamic_themes = Var.create( # type: ignore
  361. convert_dict_to_style_and_format_emotion(
  362. {
  363. "& .AccordionItem": get_theme_accordion_item(),
  364. "& .AccordionHeader": get_theme_accordion_header(),
  365. "& .AccordionTrigger": accordion_theme_trigger,
  366. "& .AccordionContent": accordion_theme_content,
  367. }
  368. )
  369. )._merge( # type: ignore
  370. accordion_theme_root
  371. )
  372. def _get_imports(self):
  373. return imports.merge_imports(
  374. super()._get_imports(),
  375. self._var_data.imports if self._var_data else {},
  376. {"@emotion/react": [imports.ImportVar(tag="keyframes")]},
  377. )
  378. def get_event_triggers(self) -> Dict[str, Any]:
  379. """Get the events triggers signatures for the component.
  380. Returns:
  381. The signatures of the event triggers.
  382. """
  383. return {
  384. **super().get_event_triggers(),
  385. "on_value_change": lambda e0: [e0],
  386. }
  387. def _get_custom_code(self) -> str:
  388. return """
  389. const slideDown = keyframes`
  390. from {
  391. height: 0;
  392. }
  393. to {
  394. height: var(--radix-accordion-content-height);
  395. }
  396. `
  397. const slideUp = keyframes`
  398. from {
  399. height: var(--radix-accordion-content-height);
  400. }
  401. to {
  402. height: 0;
  403. }
  404. `
  405. """
  406. class AccordionItem(AccordionComponent):
  407. """An accordion component."""
  408. tag = "Item"
  409. alias = "RadixAccordionItem"
  410. # A unique identifier for the item.
  411. value: Var[str]
  412. # When true, prevents the user from interacting with the item.
  413. disabled: Var[bool]
  414. _valid_children: List[str] = [
  415. "AccordionHeader",
  416. "AccordionTrigger",
  417. "AccordionContent",
  418. ]
  419. _valid_parents: List[str] = ["AccordionRoot"]
  420. def _apply_theme(self, theme: Component):
  421. self.style = Style(
  422. {
  423. **self.style,
  424. }
  425. )
  426. @classmethod
  427. def create(
  428. cls,
  429. *children,
  430. header: Optional[Component | Var] = None,
  431. content: Optional[Component | Var] = None,
  432. **props,
  433. ) -> Component:
  434. """Create an accordion item.
  435. Args:
  436. header: The header of the accordion item.
  437. content: The content of the accordion item.
  438. *children: The list of children to use if header and content are not provided.
  439. **props: Additional properties to apply to the accordion item.
  440. Returns:
  441. The accordion item.
  442. """
  443. # The item requires a value to toggle (use a random unique name if not provided).
  444. value = props.pop("value", get_unique_variable_name())
  445. if "AccordionItem" not in (
  446. cls_name := props.pop("class_name", "AccordionItem")
  447. ):
  448. cls_name = f"{cls_name} AccordionItem"
  449. if (header is not None) and (content is not None):
  450. children = [
  451. AccordionHeader.create(
  452. AccordionTrigger.create(
  453. header,
  454. AccordionIcon.create(),
  455. ),
  456. ),
  457. AccordionContent.create(content),
  458. ]
  459. return super().create(*children, value=value, **props, class_name=cls_name)
  460. class AccordionHeader(AccordionComponent):
  461. """An accordion component."""
  462. tag = "Header"
  463. alias = "RadixAccordionHeader"
  464. @classmethod
  465. def create(cls, *children, **props) -> Component:
  466. """Create the Accordion header component.
  467. Args:
  468. *children: The children of the component.
  469. **props: The properties of the component.
  470. Returns:
  471. The Accordion header Component.
  472. """
  473. if "AccordionHeader" not in (
  474. cls_name := props.pop("class_name", "AccordionHeader")
  475. ):
  476. cls_name = f"{cls_name} AccordionHeader"
  477. return super().create(*children, class_name=cls_name, **props)
  478. def _apply_theme(self, theme: Component):
  479. self.style = Style({**self.style})
  480. class AccordionTrigger(AccordionComponent):
  481. """An accordion component."""
  482. tag = "Trigger"
  483. alias = "RadixAccordionTrigger"
  484. @classmethod
  485. def create(cls, *children, **props) -> Component:
  486. """Create the Accordion trigger component.
  487. Args:
  488. *children: The children of the component.
  489. **props: The properties of the component.
  490. Returns:
  491. The Accordion trigger Component.
  492. """
  493. if "AccordionTrigger" not in (
  494. cls_name := props.pop("class_name", "AccordionTrigger")
  495. ):
  496. cls_name = f"{cls_name} AccordionTrigger"
  497. return super().create(*children, class_name=cls_name, **props)
  498. def _apply_theme(self, theme: Component):
  499. self.style = Style({**self.style})
  500. class AccordionIcon(Icon):
  501. """An accordion icon component."""
  502. @classmethod
  503. def create(cls, *children, **props) -> Component:
  504. """Create the Accordion icon component.
  505. Args:
  506. *children: The children of the component.
  507. **props: The properties of the component.
  508. Returns:
  509. The Accordion icon Component.
  510. """
  511. if "AccordionChevron" not in (
  512. cls_name := props.pop("class_name", "AccordionChevron")
  513. ):
  514. cls_name = f"{cls_name} AccordionChevron"
  515. return super().create(tag="chevron_down", class_name=cls_name, **props)
  516. class AccordionContent(AccordionComponent):
  517. """An accordion component."""
  518. tag = "Content"
  519. alias = "RadixAccordionContent"
  520. @classmethod
  521. def create(cls, *children, **props) -> Component:
  522. """Create the Accordion content component.
  523. Args:
  524. *children: The children of the component.
  525. **props: The properties of the component.
  526. Returns:
  527. The Accordion content Component.
  528. """
  529. if "AccordionContent" not in (
  530. cls_name := props.pop("class_name", "AccordionContent")
  531. ):
  532. cls_name = f"{cls_name} AccordionContent"
  533. return super().create(*children, class_name=cls_name, **props)
  534. def _apply_theme(self, theme: Component):
  535. self.style = Style({**self.style})
  536. # def _get_imports(self):
  537. # return {
  538. # **super()._get_imports(),
  539. # "@emotion/react": [imports.ImportVar(tag="keyframes")],
  540. # }
  541. class Accordion(SimpleNamespace):
  542. """Accordion component."""
  543. content = staticmethod(AccordionContent.create)
  544. header = staticmethod(AccordionHeader.create)
  545. item = staticmethod(AccordionItem.create)
  546. icon = staticmethod(AccordionIcon.create)
  547. root = staticmethod(AccordionRoot.create)
  548. trigger = staticmethod(AccordionTrigger.create)
  549. accordion = Accordion()