Skip to content

Commit 1e5121f

Browse files
committed
[Improvement] Align Tooltip with shadcn API and behavior
- Replace single `placement` value with `side` / `align` / `side_offset` props to match shadcn/ui's positioning API - Add `delay_duration` (defaults to 0, matching shadcn's override of Radix's 700ms); pointer hover honors the delay, focus opens immediately - Add Tooltip arrow rendered as a rotated <span> via Floating UI's `arrow()` middleware - Add `flip()` middleware so the tooltip switches sides automatically when hitting the viewport edge - Switch from `peer-hover` + `invisible` to `hidden` attribute toggling driven by `data-state="open|closed"` (Radix/shadcn data-attribute pattern), which enables clean enter/exit animations - Use `position: fixed` strategy so ancestor `overflow: hidden` no longer clips the tooltip - Wire the trigger via Stimulus actions (mouseenter / mouseleave + focusin / focusout) instead of CSS sibling selectors - Update content styling to match shadcn (`bg-foreground` / `text-background`) - Document Sides, Delay duration, and Side offset usage; expand tests to cover the new API surface
1 parent 7c86e33 commit 1e5121f

8 files changed

Lines changed: 338 additions & 53 deletions

File tree

Lines changed: 99 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,118 @@
11
import { Controller } from "@hotwired/stimulus";
2-
import { computePosition, autoUpdate, offset, shift } from "@floating-ui/dom";
2+
import { computePosition, autoUpdate, offset, shift, flip, arrow } from "@floating-ui/dom";
3+
4+
const STATIC_SIDE = { top: "bottom", right: "left", bottom: "top", left: "right" };
35

46
export default class extends Controller {
5-
static targets = ["trigger", "content"];
6-
static values = { placement: String }
7+
static targets = ["trigger", "content", "arrow"];
8+
static values = {
9+
side: { type: String, default: "top" },
10+
align: { type: String, default: "center" },
11+
sideOffset: { type: Number, default: 0 },
12+
delayDuration: { type: Number, default: 0 }
13+
};
714

8-
constructor(...args) {
9-
super(...args);
10-
this.cleanup;
15+
initialize() {
16+
this.openTimeout = null;
17+
this.cleanup = null;
1118
}
1219

1320
connect() {
14-
this.setFloatingElement();
21+
this.triggerTarget.setAttribute("aria-describedby", this.contentTarget.id);
22+
}
1523

16-
const tooltipId = this.contentTarget.getAttribute("id");
17-
this.triggerTarget.setAttribute("aria-describedby", tooltipId);
24+
disconnect() {
25+
this.clearTimer();
26+
this.stopAutoUpdate();
27+
}
1828

29+
open() {
30+
this.clearTimer();
31+
if (this.delayDurationValue > 0) {
32+
this.openTimeout = setTimeout(() => this.show(), this.delayDurationValue);
33+
} else {
34+
this.show();
35+
}
1936
}
2037

21-
disconnect() {
22-
this.cleanup();
38+
openImmediate() {
39+
this.clearTimer();
40+
this.show();
41+
}
42+
43+
close() {
44+
this.clearTimer();
45+
this.hide();
46+
}
47+
48+
show() {
49+
this.contentTarget.hidden = false;
50+
this.contentTarget.setAttribute("data-state", "open");
51+
this.startAutoUpdate();
52+
}
53+
54+
hide() {
55+
this.contentTarget.setAttribute("data-state", "closed");
56+
this.contentTarget.hidden = true;
57+
this.stopAutoUpdate();
2358
}
2459

25-
setFloatingElement() {
60+
startAutoUpdate() {
61+
this.stopAutoUpdate();
62+
63+
const middleware = [
64+
offset(this.sideOffsetValue + 4),
65+
flip(),
66+
shift({ padding: 8 })
67+
];
68+
69+
if (this.hasArrowTarget) {
70+
middleware.push(arrow({ element: this.arrowTarget, padding: 4 }));
71+
}
72+
2673
this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => {
2774
computePosition(this.triggerTarget, this.contentTarget, {
28-
placement: this.placementValue,
29-
middleware: [offset(4), shift()]
30-
}).then(({ x, y }) => {
31-
Object.assign(this.contentTarget.style, {
32-
left: `${x}px`,
33-
top: `${y}px`,
34-
});
75+
strategy: "fixed",
76+
placement: this.placement,
77+
middleware
78+
}).then(({ x, y, placement, middlewareData }) => {
79+
Object.assign(this.contentTarget.style, { left: `${x}px`, top: `${y}px` });
80+
81+
const side = placement.split("-")[0];
82+
this.contentTarget.setAttribute("data-side", side);
83+
84+
if (this.hasArrowTarget && middlewareData.arrow) {
85+
const { x: arrowX, y: arrowY } = middlewareData.arrow;
86+
const staticSide = STATIC_SIDE[side];
87+
Object.assign(this.arrowTarget.style, {
88+
left: arrowX != null ? `${arrowX}px` : "",
89+
top: arrowY != null ? `${arrowY}px` : "",
90+
right: "",
91+
bottom: "",
92+
[staticSide]: `${-this.arrowTarget.offsetWidth / 2}px`
93+
});
94+
}
3595
});
3696
});
3797
}
98+
99+
stopAutoUpdate() {
100+
if (this.cleanup) {
101+
this.cleanup();
102+
this.cleanup = null;
103+
}
104+
}
105+
106+
clearTimer() {
107+
if (this.openTimeout) {
108+
clearTimeout(this.openTimeout);
109+
this.openTimeout = null;
110+
}
111+
}
112+
113+
get placement() {
114+
return this.alignValue === "center"
115+
? this.sideValue
116+
: `${this.sideValue}-${this.alignValue}`;
117+
}
38118
}

docs/app/views/docs/tooltip.rb

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,49 @@ def view_template
2424
RUBY
2525
end
2626

27+
render Docs::VisualCodeExample.new(title: "Sides", context: self) do
28+
<<~RUBY
29+
div(class: "flex flex-wrap items-center justify-center gap-4") do
30+
["top", "right", "bottom", "left"].each do |side|
31+
Tooltip(side: side) do
32+
TooltipTrigger do
33+
Button(variant: :outline) { side.capitalize }
34+
end
35+
TooltipContent do
36+
Text { "Side: \#{side}" }
37+
end
38+
end
39+
end
40+
end
41+
RUBY
42+
end
43+
44+
render Docs::VisualCodeExample.new(title: "Delay duration", context: self) do
45+
<<~RUBY
46+
Tooltip(delay_duration: 700) do
47+
TooltipTrigger do
48+
Button(variant: :outline) { "Hover (700ms delay)" }
49+
end
50+
TooltipContent do
51+
Text { "Shows after the configured delay" }
52+
end
53+
end
54+
RUBY
55+
end
56+
57+
render Docs::VisualCodeExample.new(title: "Side offset", context: self) do
58+
<<~RUBY
59+
Tooltip(side_offset: 12) do
60+
TooltipTrigger do
61+
Button(variant: :outline) { "Hover" }
62+
end
63+
TooltipContent do
64+
Text { "Pushed 12px away from the trigger" }
65+
end
66+
end
67+
RUBY
68+
end
69+
2770
render Docs::VisualCodeExample.new(title: "Long content", context: self) do
2871
<<~RUBY
2972
Tooltip do

gem/lib/ruby_ui/tooltip/tooltip.rb

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
module RubyUI
44
class Tooltip < Base
5-
def initialize(placement: "top", **attrs)
6-
@placement = placement
5+
def initialize(side: "top", align: "center", side_offset: 0, delay_duration: 0, **attrs)
6+
@side = side
7+
@align = align
8+
@side_offset = side_offset
9+
@delay_duration = delay_duration
710
super(**attrs)
811
end
912

@@ -17,9 +20,12 @@ def default_attrs
1720
{
1821
data: {
1922
controller: "ruby-ui--tooltip",
20-
ruby_ui__tooltip_placement_value: @placement
23+
ruby_ui__tooltip_side_value: @side,
24+
ruby_ui__tooltip_align_value: @align,
25+
ruby_ui__tooltip_side_offset_value: @side_offset,
26+
ruby_ui__tooltip_delay_duration_value: @delay_duration
2127
},
22-
class: "group"
28+
class: "inline-block"
2329
}
2430
end
2531
end

gem/lib/ruby_ui/tooltip/tooltip_content.rb

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,33 @@
33
module RubyUI
44
class TooltipContent < Base
55
def initialize(**attrs)
6-
@id = "tooltip#{SecureRandom.hex(4)}"
6+
@id = "tooltip-#{SecureRandom.hex(4)}"
77
super
88
end
99

1010
def view_template(&)
11-
div(**attrs, &)
11+
div(**attrs) do
12+
yield if block_given?
13+
span(
14+
data: {ruby_ui__tooltip_target: "arrow"},
15+
aria: {hidden: "true"},
16+
class: "absolute size-2.5 rotate-45 rounded-[2px] bg-foreground"
17+
)
18+
end
1219
end
1320

1421
private
1522

1623
def default_attrs
1724
{
1825
id: @id,
26+
role: "tooltip",
27+
hidden: true,
1928
data: {
20-
ruby_ui__tooltip_target: "content"
29+
ruby_ui__tooltip_target: "content",
30+
state: "closed"
2131
},
22-
class: "invisible peer-hover:visible peer-focus:visible w-fit max-w-[calc(100vw-2rem)] text-balance break-words absolute top-0 left-0 z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md peer-focus:zoom-in-95 animate-out fade-out-0 zoom-out-95 peer-hover:animate-in peer-focus:animate-in peer-hover:fade-in-0 peer-focus:fade-in-0 peer-hover:zoom-in-95 group-data-[ruby-ui--tooltip-placement-value=bottom]:slide-in-from-top-2 group-data-[ruby-ui--tooltip-placement-value=left]:slide-in-from-right-2 group-data-[ruby-ui--tooltip-placement-value=right]:slide-in-from-left-2 group-data-[ruby-ui--tooltip-placement-value=top]:slide-in-from-bottom-2 delay-500"
32+
class: "fixed top-0 left-0 z-50 w-fit max-w-[calc(100vw-2rem)] rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background break-words shadow-md animate-in fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
2333
}
2434
end
2535
end
Lines changed: 99 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,118 @@
11
import { Controller } from "@hotwired/stimulus";
2-
import { computePosition, autoUpdate, offset, shift } from "@floating-ui/dom";
2+
import { computePosition, autoUpdate, offset, shift, flip, arrow } from "@floating-ui/dom";
3+
4+
const STATIC_SIDE = { top: "bottom", right: "left", bottom: "top", left: "right" };
35

46
export default class extends Controller {
5-
static targets = ["trigger", "content"];
6-
static values = { placement: String }
7+
static targets = ["trigger", "content", "arrow"];
8+
static values = {
9+
side: { type: String, default: "top" },
10+
align: { type: String, default: "center" },
11+
sideOffset: { type: Number, default: 0 },
12+
delayDuration: { type: Number, default: 0 }
13+
};
714

8-
constructor(...args) {
9-
super(...args);
10-
this.cleanup;
15+
initialize() {
16+
this.openTimeout = null;
17+
this.cleanup = null;
1118
}
1219

1320
connect() {
14-
this.setFloatingElement();
21+
this.triggerTarget.setAttribute("aria-describedby", this.contentTarget.id);
22+
}
1523

16-
const tooltipId = this.contentTarget.getAttribute("id");
17-
this.triggerTarget.setAttribute("aria-describedby", tooltipId);
24+
disconnect() {
25+
this.clearTimer();
26+
this.stopAutoUpdate();
27+
}
1828

29+
open() {
30+
this.clearTimer();
31+
if (this.delayDurationValue > 0) {
32+
this.openTimeout = setTimeout(() => this.show(), this.delayDurationValue);
33+
} else {
34+
this.show();
35+
}
1936
}
2037

21-
disconnect() {
22-
this.cleanup();
38+
openImmediate() {
39+
this.clearTimer();
40+
this.show();
41+
}
42+
43+
close() {
44+
this.clearTimer();
45+
this.hide();
46+
}
47+
48+
show() {
49+
this.contentTarget.hidden = false;
50+
this.contentTarget.setAttribute("data-state", "open");
51+
this.startAutoUpdate();
52+
}
53+
54+
hide() {
55+
this.contentTarget.setAttribute("data-state", "closed");
56+
this.contentTarget.hidden = true;
57+
this.stopAutoUpdate();
2358
}
2459

25-
setFloatingElement() {
60+
startAutoUpdate() {
61+
this.stopAutoUpdate();
62+
63+
const middleware = [
64+
offset(this.sideOffsetValue + 4),
65+
flip(),
66+
shift({ padding: 8 })
67+
];
68+
69+
if (this.hasArrowTarget) {
70+
middleware.push(arrow({ element: this.arrowTarget, padding: 4 }));
71+
}
72+
2673
this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => {
2774
computePosition(this.triggerTarget, this.contentTarget, {
28-
placement: this.placementValue,
29-
middleware: [offset(4), shift()]
30-
}).then(({ x, y }) => {
31-
Object.assign(this.contentTarget.style, {
32-
left: `${x}px`,
33-
top: `${y}px`,
34-
});
75+
strategy: "fixed",
76+
placement: this.placement,
77+
middleware
78+
}).then(({ x, y, placement, middlewareData }) => {
79+
Object.assign(this.contentTarget.style, { left: `${x}px`, top: `${y}px` });
80+
81+
const side = placement.split("-")[0];
82+
this.contentTarget.setAttribute("data-side", side);
83+
84+
if (this.hasArrowTarget && middlewareData.arrow) {
85+
const { x: arrowX, y: arrowY } = middlewareData.arrow;
86+
const staticSide = STATIC_SIDE[side];
87+
Object.assign(this.arrowTarget.style, {
88+
left: arrowX != null ? `${arrowX}px` : "",
89+
top: arrowY != null ? `${arrowY}px` : "",
90+
right: "",
91+
bottom: "",
92+
[staticSide]: `${-this.arrowTarget.offsetWidth / 2}px`
93+
});
94+
}
3595
});
3696
});
3797
}
98+
99+
stopAutoUpdate() {
100+
if (this.cleanup) {
101+
this.cleanup();
102+
this.cleanup = null;
103+
}
104+
}
105+
106+
clearTimer() {
107+
if (this.openTimeout) {
108+
clearTimeout(this.openTimeout);
109+
this.openTimeout = null;
110+
}
111+
}
112+
113+
get placement() {
114+
return this.alignValue === "center"
115+
? this.sideValue
116+
: `${this.sideValue}-${this.alignValue}`;
117+
}
38118
}

0 commit comments

Comments
 (0)