Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 58 additions & 22 deletions docs/app/javascript/controllers/ruby_ui/tooltip_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,72 @@ import { computePosition, autoUpdate, offset, shift } from "@floating-ui/dom";

export default class extends Controller {
static targets = ["trigger", "content"];
static values = { placement: String }

constructor(...args) {
super(...args);
this.cleanup;
static values = { placement: "top" };

mount() {
if (this.mounted) return;

const element = this.cloneTemplate();
element.setAttribute("data-placement", this.placementValue);
document.body.appendChild(element);

this.triggerTarget.setAttribute("aria-describedby", element.id);
element.addEventListener("animationend", (event) => this.animationEnd(event));

const onBeforeCache = () => this.unmount();
document.addEventListener("turbo:before-cache", onBeforeCache);

this.mounted = { element, onBeforeCache };
this.mounted.stopAutoUpdate = autoUpdate(this.triggerTarget, element, () => this.reposition());
}

connect() {
this.setFloatingElement();
unmount() {
if (!this.mounted) return;

const tooltipId = this.contentTarget.getAttribute("id");
this.triggerTarget.setAttribute("aria-describedby", tooltipId);
document.removeEventListener("turbo:before-cache", this.mounted.onBeforeCache);

this.mounted.stopAutoUpdate?.();
this.mounted.element.remove();
this.triggerTarget.removeAttribute("aria-describedby");

this.mounted = null;
}

disconnect() {
this.cleanup();
}

setFloatingElement() {
this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => {
computePosition(this.triggerTarget, this.contentTarget, {
placement: this.placementValue,
middleware: [offset(4), shift()]
}).then(({ x, y }) => {
Object.assign(this.contentTarget.style, {
left: `${x}px`,
top: `${y}px`,
});
});
this.unmount();
}

show() {
if (!this.hasContentTarget) return;

this.mount();
this.mounted.element.setAttribute("data-state", "open");
}

hide() {
this.mounted?.element.setAttribute("data-state", "closed");
}

animationEnd(event) {
if (event.animationName !== "exit") return;
if (this.mounted?.element.getAttribute("data-state") !== "closed") return;

this.unmount();
}

cloneTemplate() {
return this.contentTarget.content.firstElementChild.cloneNode(true);
}

reposition() {
if (!this.mounted) return;

const position = { placement: this.placementValue, middleware: [offset(4), shift()] };

computePosition(this.triggerTarget, this.mounted.element, position).then(({ x, y }) => {
this.mounted?.element.style.setProperty("left", `${x}px`);
this.mounted?.element.style.setProperty("top", `${y}px`);
});
}
}
17 changes: 12 additions & 5 deletions gem/lib/ruby_ui/tooltip/tooltip_content.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,25 @@ def initialize(**attrs)
end

def view_template(&)
div(**attrs, &)
template(data: {ruby_ui__tooltip_target: "content"}) do
div(**attrs, &)
end
end

private

def default_attrs
{
id: @id,
data: {
ruby_ui__tooltip_target: "content"
},
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"
class: [
"invisible pointer-events-none 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",
"data-[placement=bottom]:slide-in-from-top-2",
"data-[placement=left]:slide-in-from-right-2",
"data-[placement=right]:slide-in-from-left-2",
"data-[placement=top]:slide-in-from-bottom-2",
"data-[state=open]:visible data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:visible data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:fill-mode-forwards"
]
}
end
end
Expand Down
80 changes: 58 additions & 22 deletions gem/lib/ruby_ui/tooltip/tooltip_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,72 @@ import { computePosition, autoUpdate, offset, shift } from "@floating-ui/dom";

export default class extends Controller {
static targets = ["trigger", "content"];
static values = { placement: String }

constructor(...args) {
super(...args);
this.cleanup;
static values = { placement: "top" };
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot May 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Invalid Stimulus values definition syntax. Passing a raw string "top" as the value definition is not supported by Stimulus — it expects either a Type constructor or a { type, default } object. This will cause this.placementValue to not work correctly, breaking tooltip positioning.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/lib/ruby_ui/tooltip/tooltip_controller.js, line 7:

<comment>Invalid Stimulus values definition syntax. Passing a raw string `"top"` as the value definition is not supported by Stimulus — it expects either a Type constructor or a `{ type, default }` object. This will cause `this.placementValue` to not work correctly, breaking tooltip positioning.</comment>

<file context>
@@ -3,36 +3,72 @@ import { computePosition, autoUpdate, offset, shift } from "@floating-ui/dom";
-  constructor(...args) {
-    super(...args);
-    this.cleanup;
+  static values = { placement: "top" };
+
+  mount() {
</file context>
Suggested change
static values = { placement: "top" };
static values = { placement: { type: String, default: "top" } };
Fix with Cubic


mount() {
if (this.mounted) return;

const element = this.cloneTemplate();
element.setAttribute("data-placement", this.placementValue);
document.body.appendChild(element);

this.triggerTarget.setAttribute("aria-describedby", element.id);
element.addEventListener("animationend", (event) => this.animationEnd(event));

const onBeforeCache = () => this.unmount();
document.addEventListener("turbo:before-cache", onBeforeCache);

this.mounted = { element, onBeforeCache };
this.mounted.stopAutoUpdate = autoUpdate(this.triggerTarget, element, () => this.reposition());
}

connect() {
this.setFloatingElement();
unmount() {
if (!this.mounted) return;

const tooltipId = this.contentTarget.getAttribute("id");
this.triggerTarget.setAttribute("aria-describedby", tooltipId);
document.removeEventListener("turbo:before-cache", this.mounted.onBeforeCache);

this.mounted.stopAutoUpdate?.();
this.mounted.element.remove();
this.triggerTarget.removeAttribute("aria-describedby");

this.mounted = null;
}

disconnect() {
this.cleanup();
}

setFloatingElement() {
this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => {
computePosition(this.triggerTarget, this.contentTarget, {
placement: this.placementValue,
middleware: [offset(4), shift()]
}).then(({ x, y }) => {
Object.assign(this.contentTarget.style, {
left: `${x}px`,
top: `${y}px`,
});
});
this.unmount();
}

show() {
if (!this.hasContentTarget) return;

this.mount();
this.mounted.element.setAttribute("data-state", "open");
}

hide() {
this.mounted?.element.setAttribute("data-state", "closed");
}

animationEnd(event) {
if (event.animationName !== "exit") return;
if (this.mounted?.element.getAttribute("data-state") !== "closed") return;

this.unmount();
}

cloneTemplate() {
return this.contentTarget.content.firstElementChild.cloneNode(true);
}

reposition() {
if (!this.mounted) return;

const position = { placement: this.placementValue, middleware: [offset(4), shift()] };

computePosition(this.triggerTarget, this.mounted.element, position).then(({ x, y }) => {
this.mounted?.element.style.setProperty("left", `${x}px`);
this.mounted?.element.style.setProperty("top", `${y}px`);
});
}
}
13 changes: 10 additions & 3 deletions gem/lib/ruby_ui/tooltip/tooltip_trigger.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,16 @@ def view_template(&)

def default_attrs
{
data: {ruby_ui__tooltip_target: "trigger"},
variant: :outline,
class: "peer"
data: {
ruby_ui__tooltip_target: "trigger",
action: [
"mouseenter->ruby-ui--tooltip#show",
"mouseleave->ruby-ui--tooltip#hide",
"focus->ruby-ui--tooltip#show",
"blur->ruby-ui--tooltip#hide"
]
},
variant: :outline
}
end
end
Expand Down