From 8985f81ab52990f41a6d4c0af357039aff486029 Mon Sep 17 00:00:00 2001 From: Kai Wagner Date: Fri, 28 Aug 2026 14:11:39 +0200 Subject: [PATCH] fix: stop topic-icon popovers from rendering under later table rows in Safari The topics list is a with border-collapse: collapse, and Safari ignores z-index on elements in that case. Our previous fix gave the open row position: relative + z-index, which works in Chrome/Firefox but not Safari, so a popover taller than its row kept rendering underneath the rows below it there. Instead of relying on table stacking at all, the hover-popover controller now detaches the popover to and pins it with position: fixed, computed from the trigger's bounding box, while it's open, then moves it back on hide. This avoids the table's stacking context entirely, so it renders correctly regardless of browser. Verified with Playwright in both Chromium and WebKit. Signed-off-by: Kai Wagner --- app/assets/stylesheets/components/topics.css | 5 -- .../controllers/hover_popover_controller.js | 86 ++++++++++++++++--- 2 files changed, 76 insertions(+), 15 deletions(-) diff --git a/app/assets/stylesheets/components/topics.css b/app/assets/stylesheets/components/topics.css index 94fcdd4c..cacef0d3 100644 --- a/app/assets/stylesheets/components/topics.css +++ b/app/assets/stylesheets/components/topics.css @@ -377,11 +377,6 @@ a.topic-icon { } } -.topic-row.is-popover-open { - position: relative; - z-index: 5; -} - .topic-row.topic-new, .topic-row.topic-reading { background: var(--color-bg-unread); diff --git a/app/javascript/controllers/hover_popover_controller.js b/app/javascript/controllers/hover_popover_controller.js index a94b67fd..e67fa581 100644 --- a/app/javascript/controllers/hover_popover_controller.js +++ b/app/javascript/controllers/hover_popover_controller.js @@ -1,6 +1,11 @@ import { Controller } from "@hotwired/stimulus" // Adds a small delay before hiding popovers so users can move the cursor into them. +// +// The popover is moved to and pinned with `position: fixed` while open, +// instead of staying absolutely positioned inside the table row. Safari ignores +// z-index on elements once the table uses border-collapse: collapse, so a +// popover left inside the row rendered underneath the rows below it. export default class extends Controller { static targets = ["popover"] static values = { delay: Number } @@ -8,20 +13,42 @@ export default class extends Controller { connect() { this.hideTimeout = null this.delay = this.delayValue || 150 + this.homeParent = null + this.homeNextSibling = null + this.popover = this.hasPopoverTarget ? this.popoverTarget : null + this.onScroll = () => this._closeImmediately() + + if (this.popover) { + this.onPopoverEnter = () => this.show() + this.onPopoverLeave = () => this.scheduleHide() + this.popover.addEventListener("mouseenter", this.onPopoverEnter) + this.popover.addEventListener("mouseleave", this.onPopoverLeave) + } + } + + disconnect() { + this._clearTimeout() + if (this.popover) { + this.popover.removeEventListener("mouseenter", this.onPopoverEnter) + this.popover.removeEventListener("mouseleave", this.onPopoverLeave) + } + this._returnPopoverHome() } show() { this._clearTimeout() this.element.classList.add("is-open") - this._updateRowOpenState() + this._detachPopoverToBody() } scheduleHide() { this._clearTimeout() - this.hideTimeout = setTimeout(() => { - this.element.classList.remove("is-open") - this._updateRowOpenState() - }, this.delay) + this.hideTimeout = setTimeout(() => this._closeImmediately(), this.delay) + } + + _closeImmediately() { + this.element.classList.remove("is-open") + this._returnPopoverHome() } _clearTimeout() { @@ -31,11 +58,50 @@ export default class extends Controller { } } - _updateRowOpenState() { - const row = this.element.closest(".topic-row") - if (!row) return + _detachPopoverToBody() { + if (!this.popover || this.popover.parentNode === document.body) return + + this.homeParent = this.popover.parentNode + this.homeNextSibling = this.popover.nextSibling + + document.body.appendChild(this.popover) + this.popover.style.display = "block" + this._positionPopover() + + window.addEventListener("scroll", this.onScroll, { capture: true, passive: true }) + window.addEventListener("resize", this.onScroll) + } + + _positionPopover() { + const alignRight = this.element.closest(".topic-participants") !== null + const rect = this.element.getBoundingClientRect() + + this.popover.style.position = "fixed" + this.popover.style.top = `${rect.bottom + 6}px` + + if (alignRight) { + this.popover.style.left = "auto" + this.popover.style.right = `${window.innerWidth - rect.right}px` + } else { + this.popover.style.left = `${rect.left}px` + this.popover.style.right = "auto" + } + } + + _returnPopoverHome() { + if (!this.popover || !this.homeParent) return + + window.removeEventListener("scroll", this.onScroll, { capture: true }) + window.removeEventListener("resize", this.onScroll) + + this.popover.style.display = "" + this.popover.style.position = "" + this.popover.style.top = "" + this.popover.style.left = "" + this.popover.style.right = "" - const hasOpenPopover = row.querySelector(".topic-icon.is-open") - row.classList.toggle("is-popover-open", Boolean(hasOpenPopover)) + this.homeParent.insertBefore(this.popover, this.homeNextSibling) + this.homeParent = null + this.homeNextSibling = null } }