Skip to content

Commit 44cf40e

Browse files
authored
ENG-6165: Tooltip component (#12)
* ENG-6165: Tooltip component * add data slots
1 parent 69e4bfe commit 44cf40e

File tree

4 files changed

+345
-6
lines changed

4 files changed

+345
-6
lines changed

demo/demo/demo.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,16 @@
88
def index() -> rx.Component:
99
# Welcome Page (Index)
1010
return rx.el.div(
11-
ui.button(
12-
ui.icon("SmileIcon"),
13-
"Click me",
14-
on_click=rx.toast.success(
15-
"You are cool :)",
16-
position="top-center",
11+
ui.tooltip(
12+
ui.button(
13+
ui.icon("SmileIcon"),
14+
"Click me",
15+
on_click=rx.toast.success(
16+
"You are cool :)",
17+
position="top-center",
18+
),
1719
),
20+
content="Seriously, click me",
1821
),
1922
ui.slider(
2023
on_value_committed=lambda value: rx.toast.success(f"Value: {value}"),

reflex_ui/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"components.base.slider": ["slider"],
1313
"components.base.switch": ["switch"],
1414
"components.base.theme_switcher": ["theme_switcher"],
15+
"components.base.tooltip": ["tooltip"],
1516
}
1617

1718
_SUBMODULES = {"components", "utils"}
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
"""Tooltip component from base-ui components."""
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.components.icons.others import arrow_svg
12+
13+
LiteralSide = Literal["top", "right", "bottom", "left", "inline-end", "inline-start"]
14+
LiteralAlign = Literal["start", "center", "end"]
15+
LiteralPositionMethod = Literal["absolute", "fixed"]
16+
LiteralTrackCursorAxis = Literal["none", "bottom", "x", "y"]
17+
18+
19+
# Constants for default class names
20+
class ClassNames:
21+
"""Class names for tooltip components."""
22+
23+
TRIGGER = "inline-flex items-center justify-center"
24+
POPUP = "z-50 rounded-sm bg-secondary-12 px-3 py-1.5 text-balance text-sm font-medium text-secondary-1 shadow-small transition-all duration-150 data-[ending-style]:scale-90 data-[ending-style]:opacity-0 data-[starting-style]:scale-90 data-[starting-style]:opacity-0"
25+
ARROW = "data-[side=bottom]:top-[-7.5px] data-[side=left]:right-[-12.5px] data-[side=left]:rotate-90 data-[side=right]:left-[-12.5px] data-[side=right]:-rotate-90 data-[side=top]:bottom-[-7.5px] data-[side=top]:rotate-180"
26+
27+
28+
class TooltipBaseComponent(BaseUIComponent):
29+
"""Base component for tooltip components."""
30+
31+
library = f"{PACKAGE_NAME}/tooltip"
32+
33+
@property
34+
def import_var(self):
35+
"""Return the import variable for the tooltip component."""
36+
return ImportVar(tag="Tooltip", package_path="", install=False)
37+
38+
39+
class TooltipProvider(TooltipBaseComponent):
40+
"""Provider component for tooltips."""
41+
42+
tag = "Tooltip.Provider"
43+
44+
# How long to wait before opening a tooltip. Specified in milliseconds.
45+
delay: Var[int]
46+
47+
# How long to wait before closing a tooltip. Specified in milliseconds.
48+
close_delay: Var[int]
49+
50+
# Another tooltip will open instantly if the previous tooltip is closed within this timeout. Specified in milliseconds. Defaults to 400.
51+
timeout: Var[int]
52+
53+
@classmethod
54+
def create(cls, *children, **props) -> Component:
55+
"""Create the tooltip provider component."""
56+
props["data-slot"] = "tooltip-provider"
57+
return super().create(*children, **props)
58+
59+
60+
class TooltipRoot(TooltipBaseComponent):
61+
"""Root component for a tooltip."""
62+
63+
tag = "Tooltip.Root"
64+
65+
# Whether the tooltip is currently open.
66+
open: Var[bool]
67+
68+
# Whether the tooltip is initially open. To render a controlled tooltip, use the open prop instead. Defaults to False.
69+
default_open: Var[bool]
70+
71+
# Event handler called when the tooltip is opened or closed.
72+
on_open_change: EventHandler[passthrough_event_spec(bool, dict, str)]
73+
74+
# Event handler called after any animations complete when the tooltip is opened or closed.
75+
on_open_change_complete: EventHandler[passthrough_event_spec(bool)]
76+
77+
# Determines which axis the tooltip should track the cursor on. Defaults to "None".
78+
track_cursor_axis: Var[LiteralTrackCursorAxis]
79+
80+
# Whether the tooltip is disabled. Defaults to False.
81+
disabled: Var[bool]
82+
83+
# How long to wait before opening the tooltip. Specified in milliseconds. Defaults to 600.
84+
delay: Var[int]
85+
86+
# How long to wait before closing the tooltip. Specified in milliseconds. Defaults to 0.
87+
close_delay: Var[int]
88+
89+
# Whether the tooltip contents can be hovered without closing the tooltip. Defaults to True.
90+
hoverable: Var[bool]
91+
92+
@classmethod
93+
def create(cls, *children, **props) -> Component:
94+
"""Create the tooltip root component."""
95+
props["data-slot"] = "tooltip-root"
96+
return super().create(*children, **props)
97+
98+
99+
class TooltipTrigger(TooltipBaseComponent):
100+
"""Trigger element for the tooltip."""
101+
102+
tag = "Tooltip.Trigger"
103+
104+
# The render prop
105+
render_: Var[Component]
106+
107+
@classmethod
108+
def create(cls, *children, **props) -> Component:
109+
"""Create the tooltip trigger component."""
110+
props["data-slot"] = "tooltip-trigger"
111+
cls.set_class_name(ClassNames.TRIGGER, props)
112+
return super().create(*children, **props)
113+
114+
115+
class TooltipPortal(TooltipBaseComponent):
116+
"""Portal that moves the tooltip to a different part of the DOM."""
117+
118+
tag = "Tooltip.Portal"
119+
120+
# A parent element to render the portal element into.
121+
container: Var[str]
122+
123+
# Whether to keep the portal mounted in the DOM while the popup is hidden. Defaults to False.
124+
keep_mounted: Var[bool]
125+
126+
127+
class TooltipPositioner(TooltipBaseComponent):
128+
"""Positions the tooltip relative to the trigger."""
129+
130+
tag = "Tooltip.Positioner"
131+
132+
# How to align the popup relative to the specified side. Defaults to "center".
133+
align: Var[LiteralAlign]
134+
135+
# Additional offset along the alignment axis in pixels. Defaults to 0.
136+
align_offset: Var[int]
137+
138+
# Which side of the anchor element to align the popup against. May automatically change to avoid collisions. Defaults to "top".
139+
side: Var[LiteralSide]
140+
141+
# Distance between the anchor and the popup in pixels. Defaults to 0.
142+
side_offset: Var[int]
143+
144+
# 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.
145+
arrow_padding: Var[int]
146+
147+
# An element to position the popup against. By default, the popup will be positioned against the trigger.
148+
anchor: Var[str]
149+
150+
# An element or a rectangle that delimits the area that the popup is confined to. Defaults to "clipping-ancestors".
151+
collision_boundary: Var[str]
152+
153+
# Additional space to maintain from the edge of the collision boundary. Defaults to 5.
154+
collision_padding: Var[int]
155+
156+
# Whether to maintain the popup in the viewport after the anchor element was scrolled out of view. Defaults to False.
157+
sticky: Var[bool]
158+
159+
# Determines which CSS position property to use. Defaults to "absolute".
160+
position_method: Var[LiteralPositionMethod]
161+
162+
# Indicates whether the tooltip should track the anchor's position
163+
track_anchor: Var[bool]
164+
165+
# Determines how to handle collisions when positioning the popup.
166+
collision_avoidance: Var[str]
167+
168+
# Render prop for the positioner
169+
render_: Var[Component]
170+
171+
@classmethod
172+
def create(cls, *children, **props) -> Component:
173+
"""Create the tooltip positioner component."""
174+
props["data-slot"] = "tooltip-positioner"
175+
return super().create(*children, **props)
176+
177+
178+
class TooltipPopup(TooltipBaseComponent):
179+
"""Container for the tooltip content."""
180+
181+
tag = "Tooltip.Popup"
182+
183+
# Render prop for the popup
184+
render_: Var[Component]
185+
186+
@classmethod
187+
def create(cls, *children, **props) -> Component:
188+
"""Create the tooltip popup component."""
189+
props["data-slot"] = "tooltip-popup"
190+
cls.set_class_name(ClassNames.POPUP, props)
191+
return super().create(*children, **props)
192+
193+
194+
class TooltipArrow(TooltipBaseComponent):
195+
"""Arrow element for the tooltip."""
196+
197+
tag = "Tooltip.Arrow"
198+
199+
@classmethod
200+
def create(cls, *children, **props) -> Component:
201+
"""Create the tooltip arrow component."""
202+
props["data-slot"] = "tooltip-arrow"
203+
cls.set_class_name(ClassNames.ARROW, props)
204+
return super().create(*children, **props)
205+
206+
207+
class HighLevelTooltip(TooltipRoot):
208+
"""High level wrapper for the Tooltip component."""
209+
210+
# Content to display in the tooltip
211+
content: Var[str] | Component
212+
213+
# Props for different component parts
214+
_root_props = {
215+
"open",
216+
"default_open",
217+
"on_open_change",
218+
"on_open_change_complete",
219+
"track_cursor_axis",
220+
"disabled",
221+
"delay",
222+
"close_delay",
223+
"hoverable",
224+
}
225+
_portal_props = {
226+
"container",
227+
"keep_mounted",
228+
}
229+
_positioner_props = {
230+
"align",
231+
"align_offset",
232+
"side",
233+
"side_offset",
234+
"arrow_padding",
235+
"anchor",
236+
"collision_boundary",
237+
"collision_padding",
238+
"sticky",
239+
"position_method",
240+
"track_anchor",
241+
"collision_avoidance",
242+
"class_name",
243+
}
244+
245+
@classmethod
246+
def create(
247+
cls,
248+
trigger_component: Component,
249+
content: str | Component | None = None,
250+
**props,
251+
) -> Component:
252+
"""Create a high level tooltip component.
253+
254+
Args:
255+
trigger_component: The component that triggers the tooltip.
256+
content: The content to display in the tooltip.
257+
**props: Additional properties to apply to the tooltip component.
258+
259+
Returns:
260+
The tooltip component with all necessary subcomponents.
261+
"""
262+
# Extract content from props if provided there
263+
if content is None and "content" in props:
264+
content = props.pop("content")
265+
266+
# Extract props for different parts
267+
root_props = {k: props.pop(k) for k in cls._root_props & props.keys()}
268+
portal_props = {k: props.pop(k) for k in cls._portal_props & props.keys()}
269+
positioner_props = {
270+
k: props.pop(k) for k in cls._positioner_props & props.keys()
271+
}
272+
273+
# Set default values
274+
positioner_props.setdefault("side_offset", 8)
275+
root_props.setdefault("delay", 0)
276+
root_props.setdefault("close_delay", 0)
277+
278+
return TooltipRoot.create(
279+
TooltipTrigger.create(
280+
render_=trigger_component,
281+
),
282+
TooltipPortal.create(
283+
TooltipPositioner.create(
284+
TooltipPopup.create(
285+
TooltipArrow.create(arrow_svg()),
286+
content,
287+
),
288+
**positioner_props,
289+
),
290+
**portal_props,
291+
),
292+
**root_props,
293+
)
294+
295+
296+
class Tooltip(ComponentNamespace):
297+
"""Namespace for Tooltip components."""
298+
299+
provider = staticmethod(TooltipProvider.create)
300+
root = staticmethod(TooltipRoot.create)
301+
trigger = staticmethod(TooltipTrigger.create)
302+
portal = staticmethod(TooltipPortal.create)
303+
positioner = staticmethod(TooltipPositioner.create)
304+
popup = staticmethod(TooltipPopup.create)
305+
arrow = staticmethod(TooltipArrow.create)
306+
__call__ = staticmethod(HighLevelTooltip.create)
307+
308+
309+
tooltip = Tooltip()

reflex_ui/components/icons/others.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,29 @@ def select_arrow_icon(
6060

6161

6262
select_arrow = select_arrow_icon
63+
64+
65+
@memo
66+
def arrow_svg_component() -> Component:
67+
"""Create a tooltip arrow SVG icon.
68+
69+
The arrow SVG icon.
70+
"""
71+
return svg(
72+
svg.path(
73+
d="M9.66437 2.60207L4.80758 6.97318C4.07308 7.63423 3.11989 8 2.13172 8H0V9H20V8H18.5349C17.5468 8 16.5936 7.63423 15.8591 6.97318L11.0023 2.60207C10.622 2.2598 10.0447 2.25979 9.66437 2.60207Z",
74+
class_name="fill-secondary-12",
75+
),
76+
svg.path(
77+
d="M10.3333 3.34539L5.47654 7.71648C4.55842 8.54279 3.36693 9 2.13172 9H0V8H2.13172C3.11989 8 4.07308 7.63423 4.80758 6.97318L9.66437 2.60207C10.0447 2.25979 10.622 2.2598 11.0023 2.60207L15.8591 6.97318C16.5936 7.63423 17.5468 8 18.5349 8H20V9H18.5349C17.2998 9 16.1083 8.54278 15.1901 7.71648L10.3333 3.34539Z",
78+
class_name="fill-none",
79+
),
80+
width="20",
81+
height="10",
82+
xmlns="http://www.w3.org/2000/svg",
83+
custom_attrs={"viewBox": "0 0 20 10"},
84+
fill="none",
85+
)
86+
87+
88+
arrow_svg = arrow_svg_component

0 commit comments

Comments
 (0)