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
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export default class extends Controller {
#cleanupAutoUpdate = null;
#touchDismissTimeout = null;
#hideTimeout = null;
#hideAfterTransitionTimeout = null;
#touchPrimed = false;
#touchStarted = false;
#escapeDismissed = false;
Expand Down Expand Up @@ -226,6 +227,8 @@ export default class extends Controller {
if (!this.#tooltipElement || this.#escapeDismissed) return;

this.#hideOtherTooltips();
this.#clearHideAfterTransitionTimeout();
this.#tooltipElement.removeAttribute("hidden");

this.#tooltipElement.dataset.state = "open";
this.#tooltipElement.setAttribute("aria-hidden", "false");
Expand All @@ -243,12 +246,14 @@ export default class extends Controller {

this.#clearTouchTimeout();
this.#clearHideTimeout();
this.#clearHideAfterTransitionTimeout();
this.#touchPrimed = false;

this.#tooltipElement.dataset.state = "closed";
this.#tooltipElement.setAttribute("aria-hidden", "true");

this.#stopAutoUpdate();
this.#scheduleHideAfterTransition();
}

/**
Expand Down Expand Up @@ -402,6 +407,28 @@ export default class extends Controller {
}
}

#scheduleHideAfterTransition() {
if (!this.#tooltipElement) return;

const prefersReducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
const transitionMs = prefersReducedMotion ? 0 : 200;

this.#hideAfterTransitionTimeout = setTimeout(() => {
// Only hide if we are still closed (avoid race when reopened quickly)
if (this.#tooltipElement?.dataset.state === "closed") {
this.#tooltipElement.setAttribute("hidden", "");
}
this.#hideAfterTransitionTimeout = null;
}, transitionMs);
}

#clearHideAfterTransitionTimeout() {
if (this.#hideAfterTransitionTimeout) {
clearTimeout(this.#hideAfterTransitionTimeout);
this.#hideAfterTransitionTimeout = null;
}
}

#scheduleHide() {
this.#clearHideTimeout();
this.#hideTimeout = setTimeout(() => {
Expand Down
3 changes: 3 additions & 0 deletions app/assets/stylesheets/pathogen/components/tooltip.css
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
/* closed by default */
opacity: 0;
transform: scale(0.9);
/* Don't block interactions when visually hidden */
pointer-events: none;
transition:
opacity 200ms ease-out,
transform 200ms ease-out;
Expand All @@ -24,6 +26,7 @@
.pathogen-tooltip[data-state="open"] {
opacity: 1;
transform: scale(1);
pointer-events: auto;
}

/* Hide when native hidden attribute is present */
Expand Down
2 changes: 2 additions & 0 deletions app/assets/stylesheets/pathogen_view_components.css
Original file line number Diff line number Diff line change
Expand Up @@ -811,6 +811,7 @@
font-size: var(--pathogen-text-sm);
border-radius: var(--pathogen-radius-surface);
opacity: 0;
pointer-events: none;
transform-origin: var(--pathogen-tooltip-origin, center);
padding: .5rem .75rem;
font-weight: 500;
Expand All @@ -823,6 +824,7 @@

.pathogen-tooltip[data-state="open"] {
opacity: 1;
pointer-events: auto;
transform: scale(1);
}

Expand Down
5 changes: 5 additions & 0 deletions test/components/previews/pathogen/tooltip_preview.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ def accessibility; end
# Shows integration with Pathogen::Link component in various real-world contexts
def link_integration; end

# @label Visual Test: Click-through after hide
# Repro for "tooltip hides but still blocks clicks" issues.
# After the tooltip closes, the button underneath should be clickable immediately.
def click_through_after_hide; end

# @label Advanced Features & Edge Cases
# Demonstrates long text handling, max-width constraint, animations, and edge cases
def advanced_features; end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<div style="padding: 24px; min-height: 100vh; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;">
<div style="margin-bottom: 24px; text-align: center;">
<h1 style="margin: 0 0 8px; font-size: 28px; font-weight: 700;">
<%= pathogen_icon(:cursor_click, color: :primary, size: :xl, class: "inline mr-3") %> Tooltip Click-through After
Hide
</h1>

<p style="margin: 0 auto; max-width: 720px; color: #444;">
This preview is a manual visual regression check for a bug where a tooltip becomes hidden but still blocks clicks
on elements underneath it.
</p>
</div>

<div style="max-width: 720px; margin: 0 auto;">
<div style="border: 1px solid #ddd; border-radius: 12px; padding: 16px; margin-bottom: 16px; background: #fff;">
<h2 style="margin: 0 0 8px; font-size: 18px;">
Steps
</h2>

<ol style="margin: 0; padding-left: 20px; color: #333;">
<li>Hover the “Tooltip trigger” button to show the tooltip.</li>
<li>Move your mouse away to hide the tooltip.</li>

<li>
Immediately click the “Underlying action” button. The click counter should increment every time after the
tooltip closes.
</li>
</ol>
</div>

<div style="border: 1px solid #ddd; border-radius: 12px; padding: 20px; background: #fff;">
<div style="display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px;">
<div>
<p style="margin: 0; font-size: 12px; color: #555;">Underlying action clicks</p>
<p id="pv-tooltip-clicks" style="margin: 4px 0 0; font-size: 28px; font-weight: 700;">0</p>
</div>

<button
type="button"
id="pv-tooltip-reset"
style="padding: 8px 12px; border-radius: 8px; border: 1px solid #bbb; background: #fff;"
>
Reset
</button>
</div>

<div>
<div style="padding: 24px; border-radius: 12px; border: 1px solid #e2e2e2; background: #f7f7f7;">
<div style="display: flex; flex-direction: column; align-items: center; gap: 12px;">
<div
data-controller="pathogen--tooltip"
style="display: flex; flex-direction: column; align-items: center;"
>
<button
type="button"
style="padding: 8px 12px; border-radius: 8px; border: 1px solid #1d4ed8; background: #2563eb; color: #fff;"
aria-describedby="pv-tooltip-1"
data-pathogen--tooltip-target="trigger"
>
Tooltip trigger
</button>

<%= render Pathogen::Tooltip.new(
id: "pv-tooltip-1",
text: "I should not block clicks after I hide.",
placement: :bottom,
) %>
</div>

<button
type="button"
id="pv-underlying-action"
style="padding: 10px 14px; border-radius: 8px; border: 1px solid #047857; background: #059669; color: #fff; margin-top: -6px;"
>
Underlying action
</button>

<p style="margin: 0; font-size: 12px; color: #555; text-align: center; max-width: 520px;">
Tooltip placement is <code>bottom</code>. While open, it should overlap the button above; after it hides,
the button must be clickable immediately.
</p>
</div>
</div>
</div>

<div
style="margin-top: 16px; padding: 12px; border: 1px solid #f0c36d; border-radius: 10px; background: #fff7e6;"
>
<div style="display: flex; align-items: flex-start; gap: 10px;">
<%= pathogen_icon(:warning, color: :warning) %>

<div style="font-size: 13px; color: #5a3b00;">
<p style="margin: 0 0 6px; font-weight: 600;">What “broken” looks like</p>

<p style="margin: 0; color: #5a3b00;">
If the tooltip is visually hidden but still sitting on top of the page (e.g. due to not being truly hidden
or still catching pointer events), clicks on “Underlying action” won’t increment the counter even though
the tooltip looks gone.
</p>
</div>
</div>
</div>
</div>
</div>
</div>

<script nonce="<%= request.content_security_policy_nonce %>">
(function () {
const clicksEl = document.getElementById("pv-tooltip-clicks");
const actionBtn = document.getElementById("pv-underlying-action");
const resetBtn = document.getElementById("pv-tooltip-reset");
if (!clicksEl || !actionBtn || !resetBtn) return;

let clicks = 0;
const render = () => {
clicksEl.textContent = String(clicks);
};

actionBtn.addEventListener("click", () => {
clicks += 1;
render();
});

resetBtn.addEventListener("click", () => {
clicks = 0;
render();
});
})();
</script>
33 changes: 33 additions & 0 deletions test/javascript/controllers/tooltip_controller.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,39 @@ describe("tooltip_controller", () => {
expect(tooltip.getAttribute("aria-hidden")).toBe("true");
});

it("sets hidden after fade-out so closed tooltips don't block interactions", async () => {
const { container, tooltip } = appendTooltip();
await waitForController();

vi.useFakeTimers();
try {
const controller = application.getControllerForElementAndIdentifier(container, "pathogen--tooltip");
controller.show();
controller.hide();

// hidden is applied after the CSS transition completes (200ms)
expect(tooltip.hasAttribute("hidden")).toBe(false);
vi.advanceTimersByTime(200);
expect(tooltip.hasAttribute("hidden")).toBe(true);
} finally {
vi.useRealTimers();
}
});

it("removes hidden before showing (re-open after hide)", async () => {
const { container, tooltip } = appendTooltip();
await waitForController();

tooltip.setAttribute("hidden", "");

const controller = application.getControllerForElementAndIdentifier(container, "pathogen--tooltip");
controller.show();

expect(tooltip.hasAttribute("hidden")).toBe(false);
expect(tooltip.dataset.state).toBe("open");
expect(tooltip.getAttribute("aria-hidden")).toBe("false");
});

it("hides tooltip on Escape key press", async () => {
const { container, tooltip } = appendTooltip();
await waitForController();
Expand Down
Loading