Skip to content

Commit 0872572

Browse files
authored
ENG-6157: Popover component (#17)
1 parent 500361b commit 0872572

File tree

3 files changed

+366
-2
lines changed

3 files changed

+366
-2
lines changed

reflex_ui/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"components.base.card": ["card"],
1010
"components.base.checkbox": ["checkbox"],
1111
"components.base.dialog": ["dialog"],
12+
"components.base.popover": ["popover"],
1213
"components.base.scroll_area": ["scroll_area"],
1314
"components.base.select": ["select"],
1415
"components.base.skeleton": ["skeleton"],
Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
"""Custom popover component."""
2+
3+
from typing import Literal
4+
5+
from reflex.components.component import Component, ComponentNamespace
6+
from reflex.event import EventHandler, passthrough_event_spec
7+
from reflex.utils.imports import ImportVar
8+
from reflex.vars import Var
9+
10+
from reflex_ui.components.base_ui import PACKAGE_NAME, BaseUIComponent
11+
from reflex_ui.utils.twmerge import cn
12+
13+
LiteralAlign = Literal["start", "center", "end"]
14+
LiteralSide = Literal["bottom", "inline-end", "inline-start", "left", "right", "top"]
15+
LiteralPosition = Literal["absolute", "fixed"]
16+
17+
18+
class ClassNames:
19+
"""Class names for popover components."""
20+
21+
ROOT = ""
22+
TRIGGER = ""
23+
BACKDROP = ""
24+
PORTAL = ""
25+
POSITIONER = ""
26+
POPUP = "origin-(--transform-origin) rounded-xl p-1 border border-secondary-a4 bg-secondary-1 shadow-large transition-[transform,scale,opacity] data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 outline-none min-w-36 flex flex-col gap-2"
27+
ARROW = "data-[side=bottom]:top-[-8px] data-[side=left]:right-[-13px] data-[side=left]:rotate-90 data-[side=right]:left-[-13px] data-[side=right]:-rotate-90 data-[side=top]:bottom-[-8px] data-[side=top]:rotate-180"
28+
TITLE = "text-lg font-semibold text-secondary-12"
29+
DESCRIPTION = "text-sm text-secondary-11 font-[450]"
30+
CLOSE = ""
31+
32+
33+
class PopoverBaseComponent(BaseUIComponent):
34+
"""Base component for popover components."""
35+
36+
library = f"{PACKAGE_NAME}/popover"
37+
38+
@property
39+
def import_var(self):
40+
"""Return the import variable for the popover component."""
41+
return ImportVar(tag="Popover", package_path="", install=False)
42+
43+
44+
class PopoverRoot(PopoverBaseComponent):
45+
"""Groups all parts of the popover. Doesn't render its own HTML element."""
46+
47+
tag = "Popover.Root"
48+
49+
# Whether the popover is initially open. To render a controlled popover, use the open prop instead. Defaults to False.
50+
default_open: Var[bool]
51+
52+
# Whether the popover is currently open.
53+
open: Var[bool]
54+
55+
# Event handler called when the popover is opened or closed
56+
on_open_change: EventHandler[passthrough_event_spec(bool, dict, str)]
57+
58+
# Event handler called after any animations complete when the popover is opened or closed.
59+
on_open_change_complete: EventHandler[passthrough_event_spec(bool)]
60+
61+
# Determines if the popover enters a modal state when open.
62+
# - True: user interaction is limited to just the popover: focus is trapped, document page scroll is locked, and pointer interactions on outside elements are disabled.
63+
# - False: user interaction with the rest of the document is allowed.
64+
# - 'trap-focus': focus is trapped inside the popover, but document page scroll is not locked and pointer interactions outside of it remain enabled.
65+
modal: Var[bool | Literal["trap-focus"]]
66+
67+
# Whether the popover should also open when the trigger is hovered. Defaults to False.
68+
open_on_hover: Var[bool]
69+
70+
# How long to wait before the popover may be opened on hover. Specified in milliseconds. Requires the open_on_hover prop. Defaults to 300.
71+
delay: Var[int]
72+
73+
# How long to wait before closing the popover that was opened on hover. Specified in milliseconds. Requires the open_on_hover prop. Defaults to 0.
74+
close_delay: Var[int]
75+
76+
@classmethod
77+
def create(cls, *children, **props) -> Component:
78+
"""Create the popover root component."""
79+
props["data-slot"] = "popover"
80+
return super().create(*children, **props)
81+
82+
83+
class PopoverTrigger(PopoverBaseComponent):
84+
"""A button that opens the popover. Renders a <button> element."""
85+
86+
tag = "Popover.Trigger"
87+
88+
# 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.
89+
native_button: Var[bool]
90+
91+
# The render prop
92+
render_: Var[Component]
93+
94+
@classmethod
95+
def create(cls, *children, **props) -> Component:
96+
"""Create the popover trigger component."""
97+
props["data-slot"] = "popover-trigger"
98+
cls.set_class_name(ClassNames.TRIGGER, props)
99+
return super().create(*children, **props)
100+
101+
102+
class PopoverBackdrop(PopoverBaseComponent):
103+
"""An overlay displayed beneath the popup. Renders a <div> element."""
104+
105+
tag = "Popover.Backdrop"
106+
107+
# The render prop
108+
render_: Var[Component]
109+
110+
@classmethod
111+
def create(cls, *children, **props) -> Component:
112+
"""Create the popover backdrop component."""
113+
props["data-slot"] = "popover-backdrop"
114+
cls.set_class_name(ClassNames.BACKDROP, props)
115+
return super().create(*children, **props)
116+
117+
118+
class PopoverPortal(PopoverBaseComponent):
119+
"""A portal element that moves the popup to a different part of the DOM. By default, the portal element is appended to <body>."""
120+
121+
tag = "Popover.Portal"
122+
123+
# A parent element to render the portal element into.
124+
container: Var[str]
125+
126+
# Whether to keep the portal mounted in the DOM while the popup is hidden. Defaults to False.
127+
keep_mounted: Var[bool]
128+
129+
130+
class PopoverPositioner(PopoverBaseComponent):
131+
"""Positions the popover against the trigger. Renders a <div> element."""
132+
133+
tag = "Popover.Positioner"
134+
135+
# How to align the popup relative to the specified side. Defaults to "center".
136+
align: Var[LiteralAlign]
137+
138+
# Additional offset along the alignment axis in pixels. Defaults to 0.
139+
align_offset: Var[int]
140+
141+
# Which side of the anchor element to align the popup against. May automatically change to avoid collisions Defaults to "bottom".
142+
side: Var[LiteralSide]
143+
144+
# Distance between the anchor and the popup in pixels. Defaults to 0.
145+
side_offset: Var[int]
146+
147+
# Minimum distance to maintain between the arrow and the edges of the popup. Use it to prevent the arrow element from hanging out of the rounded corners of a popup. Defaults to 5.
148+
arrow_padding: Var[int]
149+
150+
# An element to position the popup against. By default, the popup will be positioned against the trigger.
151+
anchor: Var[str]
152+
153+
# An element or a rectangle that delimits the area that the popup is confined to. Defaults to "clipping-ancestors".
154+
collision_boundary: Var[str]
155+
156+
# Additional space to maintain from the edge of the collision boundary. Defaults to 5.
157+
collision_padding: Var[int | list[int]]
158+
159+
# Whether to maintain the popup in the viewport after the anchor element was scrolled out of view. Defaults to False.
160+
sticky: Var[bool]
161+
162+
# Determines which CSS position property to use. Defaults to "absolute".
163+
position_method: Var[LiteralPosition]
164+
165+
# Whether the popup tracks any layout shift of its positioning anchor. Defaults to True.
166+
track_anchor: Var[bool]
167+
168+
# Determines how to handle collisions when positioning the popup.
169+
collision_avoidance: Var[str]
170+
171+
# The render prop
172+
render_: Var[Component]
173+
174+
@classmethod
175+
def create(cls, *children, **props) -> Component:
176+
"""Create the popover positioner component."""
177+
props["data-slot"] = "popover-positioner"
178+
props.setdefault("side_offset", 4)
179+
cls.set_class_name(ClassNames.POSITIONER, props)
180+
return super().create(*children, **props)
181+
182+
183+
class PopoverPopup(PopoverBaseComponent):
184+
"""A container for the popover contents. Renders a <div> element."""
185+
186+
tag = "Popover.Popup"
187+
188+
# Determines the element to focus when the popover is opened. By default, the first focusable element is focused.
189+
initial_focus: Var[str]
190+
191+
# Determines the element to focus when the popover is closed. By default, focus returns to the trigger.
192+
final_focus: Var[str]
193+
194+
# The render prop
195+
render_: Var[Component]
196+
197+
@classmethod
198+
def create(cls, *children, **props) -> Component:
199+
"""Create the popover popup component."""
200+
props["data-slot"] = "popover-popup"
201+
cls.set_class_name(ClassNames.POPUP, props)
202+
return super().create(*children, **props)
203+
204+
205+
class PopoverArrow(PopoverBaseComponent):
206+
"""Displays an element positioned against the popover anchor. Renders a <div> element."""
207+
208+
tag = "Popover.Arrow"
209+
210+
# The render prop
211+
render_: Var[Component]
212+
213+
@classmethod
214+
def create(cls, *children, **props) -> Component:
215+
"""Create the popover arrow component."""
216+
props["data-slot"] = "popover-arrow"
217+
cls.set_class_name(ClassNames.ARROW, props)
218+
return super().create(*children, **props)
219+
220+
221+
class PopoverTitle(PopoverBaseComponent):
222+
"""A heading that labels the popover. Renders an <h2> element."""
223+
224+
tag = "Popover.Title"
225+
226+
# The render prop
227+
render_: Var[Component]
228+
229+
@classmethod
230+
def create(cls, *children, **props) -> Component:
231+
"""Create the popover title component."""
232+
props["data-slot"] = "popover-title"
233+
cls.set_class_name(ClassNames.TITLE, props)
234+
return super().create(*children, **props)
235+
236+
237+
class PopoverDescription(PopoverBaseComponent):
238+
"""A paragraph with additional information about the popover. Renders a <p> element."""
239+
240+
tag = "Popover.Description"
241+
242+
# The render prop
243+
render_: Var[Component]
244+
245+
@classmethod
246+
def create(cls, *children, **props) -> Component:
247+
"""Create the popover description component."""
248+
props["data-slot"] = "popover-description"
249+
cls.set_class_name(ClassNames.DESCRIPTION, props)
250+
return super().create(*children, **props)
251+
252+
253+
class PopoverClose(PopoverBaseComponent):
254+
"""A button that closes the popover. Renders a <button> element."""
255+
256+
tag = "Popover.Close"
257+
258+
# The render prop
259+
render_: Var[Component]
260+
261+
@classmethod
262+
def create(cls, *children, **props) -> Component:
263+
"""Create the popover close component."""
264+
props["data-slot"] = "popover-close"
265+
cls.set_class_name(ClassNames.CLOSE, props)
266+
return super().create(*children, **props)
267+
268+
269+
class HighLevelPopover(PopoverRoot):
270+
"""High level wrapper for the Popover component."""
271+
272+
# Popover props
273+
trigger: Var[Component | None]
274+
content: Var[str | Component | None]
275+
title: Var[str | Component | None]
276+
description: Var[str | Component | None]
277+
278+
# Props for different component parts
279+
_positioner_props = {
280+
"align",
281+
"align_offset",
282+
"side",
283+
"side_offset",
284+
"arrow_padding",
285+
"collision_padding",
286+
"collision_boundary",
287+
"sticky",
288+
"position_method",
289+
"track_anchor",
290+
"anchor",
291+
"collision_avoidance",
292+
}
293+
_portal_props = {"container", "keep_mounted"}
294+
295+
@classmethod
296+
def create(cls, *children, **props) -> Component:
297+
"""Create a popover component.
298+
299+
Args:
300+
*children: Additional children to include in the popover.
301+
**props: Additional properties to apply to the popover component.
302+
303+
Returns:
304+
The popover component.
305+
"""
306+
# Extract props for different parts
307+
positioner_props = {
308+
k: props.pop(k) for k in cls._positioner_props & props.keys()
309+
}
310+
portal_props = {k: props.pop(k) for k in cls._portal_props & props.keys()}
311+
312+
trigger = props.pop("trigger", None)
313+
content = props.pop("content", None)
314+
title = props.pop("title", None)
315+
description = props.pop("description", None)
316+
class_name = props.pop("class_name", "")
317+
318+
return PopoverRoot.create(
319+
PopoverTrigger.create(render_=trigger) if trigger else None,
320+
PopoverPortal.create(
321+
PopoverPositioner.create(
322+
PopoverPopup.create(
323+
PopoverTitle.create(title) if title else None,
324+
PopoverDescription.create(description) if description else None,
325+
content,
326+
*children,
327+
class_name=cn(ClassNames.POPUP, class_name),
328+
),
329+
**positioner_props,
330+
),
331+
**portal_props,
332+
),
333+
**props,
334+
)
335+
336+
def _exclude_props(self) -> list[str]:
337+
return [
338+
*super()._exclude_props(),
339+
"trigger",
340+
"content",
341+
"title",
342+
"description",
343+
]
344+
345+
346+
class Popover(ComponentNamespace):
347+
"""Namespace for Popover components."""
348+
349+
root = staticmethod(PopoverRoot.create)
350+
trigger = staticmethod(PopoverTrigger.create)
351+
backdrop = staticmethod(PopoverBackdrop.create)
352+
portal = staticmethod(PopoverPortal.create)
353+
positioner = staticmethod(PopoverPositioner.create)
354+
popup = staticmethod(PopoverPopup.create)
355+
arrow = staticmethod(PopoverArrow.create)
356+
title = staticmethod(PopoverTitle.create)
357+
description = staticmethod(PopoverDescription.create)
358+
close = staticmethod(PopoverClose.create)
359+
__call__ = staticmethod(HighLevelPopover.create)
360+
361+
362+
popover = Popover()

reflex_ui/components/base/select.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,8 @@ class SelectPositioner(SelectBaseComponent):
191191
# Whether the popup tracks any layout shift of its positioning anchor. Defaults to True.
192192
track_anchor: Var[bool]
193193

194-
# Distance between the anchor and the popup in pixels.
195-
side_offset: Var[int] = Var.create(4)
194+
# Distance between the anchor and the popup in pixels. Defaults to 0.
195+
side_offset: Var[int]
196196

197197
# Determines how to handle collisions when positioning the popup.
198198
collision_avoidance: Var[str]
@@ -204,6 +204,7 @@ class SelectPositioner(SelectBaseComponent):
204204
def create(cls, *children, **props) -> Component:
205205
"""Create the dialog trigger component."""
206206
props["data-slot"] = "select-positioner"
207+
props.setdefault("side_offset", 4)
207208
cls.set_class_name(ClassNames.POSITIONER, props)
208209
return super().create(*children, **props)
209210

0 commit comments

Comments
 (0)