|
| 1 | +"""Custom Accordion component.""" |
| 2 | + |
| 3 | +from typing import Literal |
| 4 | + |
| 5 | +from reflex.components.component import Component, ComponentNamespace |
| 6 | +from reflex.components.core.foreach import foreach |
| 7 | +from reflex.components.el import Div |
| 8 | +from reflex.event import EventHandler, passthrough_event_spec |
| 9 | +from reflex.utils.imports import ImportVar |
| 10 | +from reflex.vars.base import Var |
| 11 | +from reflex.vars.object import ObjectVar |
| 12 | + |
| 13 | +from reflex_ui.components.base_ui import PACKAGE_NAME, BaseUIComponent |
| 14 | +from reflex_ui.components.icons.hugeicon import icon |
| 15 | + |
| 16 | +LiteralOrientation = Literal["horizontal", "vertical"] |
| 17 | + |
| 18 | +ITEMS_TYPE = list[dict[str, str | Component]] |
| 19 | + |
| 20 | + |
| 21 | +class ClassNames: |
| 22 | + """Class names for accordion components.""" |
| 23 | + |
| 24 | + ROOT = "flex flex-col justify-center shadow-small border border-secondary-a4 divide-y divide-secondary-a4 overflow-hidden rounded-xl" |
| 25 | + ITEM = "" |
| 26 | + HEADER = "" |
| 27 | + TRIGGER = "group relative flex w-full items-center justify-between gap-4 bg-secondary-1 hover:bg-secondary-3 px-6 py-4 text-md font-semibold text-secondary-12 transition-colors disabled:cursor-not-allowed disabled:bg-secondary-3 disabled:text-secondary-8 disabled:[&_svg]:text-secondary-8 [&_svg]:text-secondary-11" |
| 28 | + PANEL = "h-[var(--accordion-panel-height)] overflow-hidden text-base text-secondary-11 font-medium transition-[height] ease-out data-[ending-style]:h-0 data-[starting-style]:h-0 border-t border-secondary-a4" |
| 29 | + PANEL_DIV = "py-4 px-6" |
| 30 | + TRIGGER_ICON = "size-4 shrink-0 transition-all ease-out group-data-[panel-open]:scale-110 group-data-[panel-open]:rotate-45" |
| 31 | + |
| 32 | + |
| 33 | +class AccordionBaseComponent(BaseUIComponent): |
| 34 | + """Base component for accordion components.""" |
| 35 | + |
| 36 | + library = f"{PACKAGE_NAME}/accordion" |
| 37 | + |
| 38 | + @property |
| 39 | + def import_var(self): |
| 40 | + """Return the import variable for the accordion component.""" |
| 41 | + return ImportVar(tag="Accordion", package_path="", install=False) |
| 42 | + |
| 43 | + |
| 44 | +class AccordionRoot(AccordionBaseComponent): |
| 45 | + """Groups all parts of the accordion.""" |
| 46 | + |
| 47 | + tag = "Accordion.Root" |
| 48 | + |
| 49 | + # The uncontrolled value of the item(s) that should be initially expanded. To render a controlled accordion, use the `value` prop instead. |
| 50 | + default_value: Var[list[str]] |
| 51 | + |
| 52 | + # The controlled value of the item(s) that should be expanded. To render an uncontrolled accordion, use the `default_value` prop instead. |
| 53 | + value: Var[list[str]] |
| 54 | + |
| 55 | + # Event handler called when an accordion item is expanded or collapsed. Provides the new value as an argument. |
| 56 | + on_value_change: EventHandler[passthrough_event_spec(list[str])] |
| 57 | + |
| 58 | + # Allows the browser's built-in page search to find and expand the panel contents. Overrides the `keep_mounted` prop and uses `hidden="until-found"` to hide the element without removing it from the DOM. Defaults to False. |
| 59 | + hidden_until_found: Var[bool] |
| 60 | + |
| 61 | + # Whether multiple items can be open at the same time. Defaults to True. |
| 62 | + open_multiple: Var[bool] |
| 63 | + |
| 64 | + # Whether the component should ignore user interaction. Defaults to False. |
| 65 | + disabled: Var[bool] |
| 66 | + |
| 67 | + # Whether to loop keyboard focus back to the first item when the end of the list is reached while using the arrow keys. Defaults to True. |
| 68 | + loop: Var[bool] |
| 69 | + |
| 70 | + # The visual orientation of the accordion. Controls whether roving focus uses left/right or up/down arrow keys. Defaults to 'vertical'. |
| 71 | + orientation: Var[LiteralOrientation] |
| 72 | + |
| 73 | + # Whether to keep the element in the DOM while the panel is closed. This prop is ignored when hidden_until_found is used. Defaults to False. |
| 74 | + keep_mounted: Var[bool] |
| 75 | + |
| 76 | + # The render prop. |
| 77 | + render_: Var[Component] |
| 78 | + |
| 79 | + @classmethod |
| 80 | + def create(cls, *children, **props) -> BaseUIComponent: |
| 81 | + """Create the accordion root component.""" |
| 82 | + props["data-slot"] = "accordion" |
| 83 | + cls.set_class_name(ClassNames.ROOT, props) |
| 84 | + return super().create(*children, **props) |
| 85 | + |
| 86 | + |
| 87 | +class AccordionItem(AccordionBaseComponent): |
| 88 | + """Groups an accordion header with the corresponding panel.""" |
| 89 | + |
| 90 | + tag = "Accordion.Item" |
| 91 | + |
| 92 | + # The value that identifies this item. |
| 93 | + value: Var[str] |
| 94 | + |
| 95 | + # Event handler called when the panel is opened or closed. |
| 96 | + on_open_change: EventHandler[passthrough_event_spec(bool)] |
| 97 | + |
| 98 | + # Whether the component should ignore user interaction. Defaults to False. |
| 99 | + disabled: Var[bool] |
| 100 | + |
| 101 | + # The render prop. |
| 102 | + render_: Var[Component] |
| 103 | + |
| 104 | + @classmethod |
| 105 | + def create(cls, *children, **props) -> BaseUIComponent: |
| 106 | + """Create the accordion item component.""" |
| 107 | + props["data-slot"] = "accordion-item" |
| 108 | + cls.set_class_name(ClassNames.ITEM, props) |
| 109 | + return super().create(*children, **props) |
| 110 | + |
| 111 | + |
| 112 | +class AccordionHeader(AccordionBaseComponent): |
| 113 | + """A heading that labels the corresponding panel.""" |
| 114 | + |
| 115 | + tag = "Accordion.Header" |
| 116 | + |
| 117 | + # The render prop. |
| 118 | + render_: Var[Component] |
| 119 | + |
| 120 | + @classmethod |
| 121 | + def create(cls, *children, **props) -> BaseUIComponent: |
| 122 | + """Create the accordion header component.""" |
| 123 | + props["data-slot"] = "accordion-header" |
| 124 | + cls.set_class_name(ClassNames.HEADER, props) |
| 125 | + return super().create(*children, **props) |
| 126 | + |
| 127 | + |
| 128 | +class AccordionTrigger(AccordionBaseComponent): |
| 129 | + """A button that opens and closes the corresponding panel.""" |
| 130 | + |
| 131 | + tag = "Accordion.Trigger" |
| 132 | + |
| 133 | + # Whether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if the rendered element is not a button (e.g. `<div>`). Defaults to True. |
| 134 | + native_button: Var[bool] |
| 135 | + |
| 136 | + # The render prop. |
| 137 | + render_: Var[Component] |
| 138 | + |
| 139 | + @classmethod |
| 140 | + def create(cls, *children, **props) -> BaseUIComponent: |
| 141 | + """Create the accordion trigger component.""" |
| 142 | + props["data-slot"] = "accordion-trigger" |
| 143 | + cls.set_class_name(ClassNames.TRIGGER, props) |
| 144 | + return super().create(*children, **props) |
| 145 | + |
| 146 | + |
| 147 | +class AccordionPanel(AccordionBaseComponent): |
| 148 | + """A collapsible panel with the accordion item contents.""" |
| 149 | + |
| 150 | + tag = "Accordion.Panel" |
| 151 | + |
| 152 | + # Allows the browser's built-in page search to find and expand the panel contents. Overrides the `keep_mounted` prop and uses `hidden="until-found"` to hide the element without removing it from the DOM. Defaults to False. |
| 153 | + hidden_until_found: Var[bool] |
| 154 | + |
| 155 | + # Whether to keep the element in the DOM while the panel is closed. This prop is ignored when `hidden_until_found` is used. Defaults to False. |
| 156 | + keep_mounted: Var[bool] |
| 157 | + |
| 158 | + # The render prop. |
| 159 | + render_: Var[Component] |
| 160 | + |
| 161 | + @classmethod |
| 162 | + def create(cls, *children, **props) -> BaseUIComponent: |
| 163 | + """Create the accordion panel component.""" |
| 164 | + props["data-slot"] = "accordion-panel" |
| 165 | + cls.set_class_name(ClassNames.PANEL, props) |
| 166 | + return super().create(*children, **props) |
| 167 | + |
| 168 | + |
| 169 | +class HighLevelAccordion(AccordionRoot): |
| 170 | + """High level wrapper for the Accordion component.""" |
| 171 | + |
| 172 | + items: Var[ITEMS_TYPE] | ITEMS_TYPE |
| 173 | + |
| 174 | + _item_props = {"on_open_change", "disabled"} |
| 175 | + _trigger_props = {"native_button"} |
| 176 | + _panel_props = {"hidden_until_found", "keep_mounted"} |
| 177 | + |
| 178 | + @classmethod |
| 179 | + def create( |
| 180 | + cls, |
| 181 | + items: Var[ITEMS_TYPE] | ITEMS_TYPE, |
| 182 | + **props, |
| 183 | + ) -> BaseUIComponent: |
| 184 | + """Create a high level accordion component. |
| 185 | +
|
| 186 | + Args: |
| 187 | + items: List of dictionaries with 'trigger', 'content', and optional 'value' and 'disabled' keys. |
| 188 | + **props: Additional properties to apply to the accordion component. |
| 189 | +
|
| 190 | + Returns: |
| 191 | + The accordion component with all necessary subcomponents. |
| 192 | + """ |
| 193 | + # Extract props for different parts |
| 194 | + item_props = {k: props.pop(k) for k in cls._item_props & props.keys()} |
| 195 | + trigger_props = {k: props.pop(k) for k in cls._trigger_props & props.keys()} |
| 196 | + panel_props = {k: props.pop(k) for k in cls._panel_props & props.keys()} |
| 197 | + |
| 198 | + if isinstance(items, Var): |
| 199 | + accordion_items = foreach( |
| 200 | + items, |
| 201 | + lambda item: cls._create_accordion_item_dynamic( |
| 202 | + item, item_props, trigger_props, panel_props |
| 203 | + ), |
| 204 | + ) |
| 205 | + return AccordionRoot.create(accordion_items, **props) |
| 206 | + accordion_items = [ |
| 207 | + cls._create_accordion_item( |
| 208 | + item, index, item_props, trigger_props, panel_props |
| 209 | + ) |
| 210 | + for index, item in enumerate(items) |
| 211 | + ] |
| 212 | + return AccordionRoot.create(*accordion_items, **props) |
| 213 | + |
| 214 | + @classmethod |
| 215 | + def _create_trigger_icon(cls) -> Component: |
| 216 | + """Create the accordion trigger icon.""" |
| 217 | + return icon( |
| 218 | + "PlusSignIcon", |
| 219 | + class_name=ClassNames.TRIGGER_ICON, |
| 220 | + data_slot="accordion-trigger-icon", |
| 221 | + ) |
| 222 | + |
| 223 | + @classmethod |
| 224 | + def _create_accordion_item( |
| 225 | + cls, |
| 226 | + item: dict[str, str | Component], |
| 227 | + index: int, |
| 228 | + item_props: dict, |
| 229 | + trigger_props: dict, |
| 230 | + panel_props: dict, |
| 231 | + ) -> BaseUIComponent: |
| 232 | + """Create a single accordion item from a dictionary (for normal lists).""" |
| 233 | + return AccordionItem.create( |
| 234 | + AccordionHeader.create( |
| 235 | + AccordionTrigger.create( |
| 236 | + item.get("trigger"), |
| 237 | + cls._create_trigger_icon(), |
| 238 | + **trigger_props, |
| 239 | + ), |
| 240 | + ), |
| 241 | + AccordionPanel.create( |
| 242 | + Div.create( |
| 243 | + item.get("content"), |
| 244 | + class_name=ClassNames.PANEL_DIV, |
| 245 | + data_slot="accordion-panel-div", |
| 246 | + ), |
| 247 | + **panel_props, |
| 248 | + ), |
| 249 | + value=item.get("value", f"item-{index + 1}"), |
| 250 | + disabled=item.get("disabled", False), |
| 251 | + **item_props, |
| 252 | + ) |
| 253 | + |
| 254 | + @classmethod |
| 255 | + def _create_accordion_item_dynamic( |
| 256 | + cls, |
| 257 | + item: ObjectVar[dict[str, str | Component]], |
| 258 | + item_props: dict, |
| 259 | + trigger_props: dict, |
| 260 | + panel_props: dict, |
| 261 | + ) -> BaseUIComponent: |
| 262 | + """Create a single accordion item from a dictionary (for Var items).""" |
| 263 | + return AccordionItem.create( |
| 264 | + AccordionHeader.create( |
| 265 | + AccordionTrigger.create( |
| 266 | + item["trigger"], |
| 267 | + cls._create_trigger_icon(), |
| 268 | + **trigger_props, |
| 269 | + ), |
| 270 | + ), |
| 271 | + AccordionPanel.create( |
| 272 | + Div.create( |
| 273 | + item["content"], |
| 274 | + class_name=ClassNames.PANEL_DIV, |
| 275 | + data_slot="accordion-panel-div", |
| 276 | + ), |
| 277 | + **panel_props, |
| 278 | + ), |
| 279 | + value=item.get("value", ""), |
| 280 | + disabled=item.get("disabled", False).bool(), |
| 281 | + **item_props, |
| 282 | + ) |
| 283 | + |
| 284 | + |
| 285 | +class Accordion(ComponentNamespace): |
| 286 | + """Namespace for Accordion components.""" |
| 287 | + |
| 288 | + root = staticmethod(AccordionRoot.create) |
| 289 | + item = staticmethod(AccordionItem.create) |
| 290 | + header = staticmethod(AccordionHeader.create) |
| 291 | + trigger = staticmethod(AccordionTrigger.create) |
| 292 | + panel = staticmethod(AccordionPanel.create) |
| 293 | + __call__ = staticmethod(HighLevelAccordion.create) |
| 294 | + |
| 295 | + |
| 296 | +accordion = Accordion() |
0 commit comments