fix(popover): remove hidden popover from layout to kill phantom scrollbar

Flowbite re-initialises popovers on every htmx swap. A popover hidden via
Tailwind `invisible` (visibility:hidden) still occupies layout, so once
Popper parks it with a transform offset it expands the table's
overflow-x-auto wrapper and a spurious scrollbar appears (horizontal here,
vertical in #40). Add `[&.invisible]:hidden` so the popover is removed from
layout while hidden; Flowbite drops `invisible` on show, restoring display.

Relates to #40. e2e regression covers no-overflow-after-swap plus
popover-still-shows-on-hover.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 22:39:49 +02:00
parent 8637c547e4
commit b6f6da309f
3 changed files with 70 additions and 123 deletions
+55
View File
@@ -46,3 +46,58 @@ def test_finish_session_swaps_row_in_place(authenticated_page: Page, live_server
session.refresh_from_db()
assert session.timestamp_end is not None
def test_finish_session_swap_does_not_add_scrollbar(
authenticated_page: Page, live_server
):
"""Regression for the phantom horizontal scrollbar (issues #53 / #40).
Flowbite re-initialises popovers on every htmx swap; a popover hidden via
Tailwind ``invisible`` (visibility:hidden) still occupies layout, so once
Popper parks it with a transform it expands the table's overflow-x-auto
wrapper and a spurious scrollbar appears. The popover must be removed from
layout while hidden.
"""
page = authenticated_page
page.set_viewport_size({"width": 1280, "height": 800})
platform = Platform.objects.create(name="PC", icon="pc", group="PC")
# A long name guarantees a truncated NameWithIcon popover in the row.
game = Game.objects.create(name="A Very Long Game Title That Truncates")
game.platform = platform
game.save()
device = Device.objects.create(name="Desktop")
session = Session.objects.create(
game=game, device=device, timestamp_start=timezone.now()
)
page.goto(f"{live_server.url}{reverse('games:list_sessions')}")
# The fix only removes the popover from layout while it is hidden; it must
# still display on hover. Verify on the freshly-loaded page.
trigger = page.locator(f"#session-row-{session.pk} [data-popover-target]").first
popover_id = trigger.get_attribute("data-popover-target")
trigger.hover()
page.wait_for_timeout(400)
shown_display = page.evaluate(
"""(id) => getComputedStyle(document.querySelector(`[id="${id}"]`)).display""",
popover_id,
)
assert shown_display != "none", "popover stayed display:none on hover"
page.mouse.move(0, 0)
page.locator(f"#session-row-{session.pk}").locator(
'button[title="Finish session now"]'
).click()
expect(page.locator(f"#session-row-{session.pk}")).to_contain_text("")
page.wait_for_timeout(500) # allow Flowbite afterSettle re-init + Popper
# After the swap re-inits popovers, the table wrapper must not become
# horizontally scrollable (the phantom-scrollbar regression).
overflow = page.evaluate(
"""() => {
const w = document.querySelector('.overflow-x-auto');
return w.scrollWidth - w.clientWidth;
}"""
)
assert overflow <= 0, f"table wrapper overflows by {overflow}px after swap"