From b6f6da309fb60ad2e1bec27853fc223e90f1f886 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sat, 20 Jun 2026 22:39:49 +0200 Subject: [PATCH] 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 --- common/components/primitives.py | 14 ++- e2e/test_session_inplace_swap_e2e.py | 55 ++++++++++++ games/static/base.css | 124 ++------------------------- 3 files changed, 70 insertions(+), 123 deletions(-) diff --git a/common/components/primitives.py b/common/components/primitives.py index 500211d..305c440 100644 --- a/common/components/primitives.py +++ b/common/components/primitives.py @@ -138,10 +138,16 @@ def _popover_html( ) popover_tooltip_class = ( - "absolute z-10 invisible inline-block text-sm text-white " - "transition-opacity duration-300 bg-white border border-purple-200 " - "rounded-lg shadow-xs opacity-0 dark:text-white dark:border-purple-600 " - "dark:bg-purple-800" + # `[&.invisible]:hidden`: while Flowbite keeps the popover hidden it + # carries the `invisible` class (visibility:hidden), which still + # occupies layout — an absolutely-positioned, Popper-transformed + # popover then expands its scroll container, producing a phantom + # scrollbar (issue #53 / #40). Removing it from layout while hidden + # fixes that; Flowbite drops `invisible` on show, restoring display. + "absolute z-10 invisible [&.invisible]:hidden inline-block text-sm " + "text-white transition-opacity duration-300 bg-white border " + "border-purple-200 rounded-lg shadow-xs opacity-0 dark:text-white " + "dark:border-purple-600 dark:bg-purple-800" ) div = Div( diff --git a/e2e/test_session_inplace_swap_e2e.py b/e2e/test_session_inplace_swap_e2e.py index 5908c87..b5985de 100644 --- a/e2e/test_session_inplace_swap_e2e.py +++ b/e2e/test_session_inplace_swap_e2e.py @@ -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" diff --git a/games/static/base.css b/games/static/base.css index 0c1bf77..ae4a8c3 100644 --- a/games/static/base.css +++ b/games/static/base.css @@ -1487,9 +1487,6 @@ .h-10 { height: calc(var(--spacing) * 10); } - .h-12 { - height: calc(var(--spacing) * 12); - } .h-\[calc\(100\%-1rem\)\] { height: calc(100% - 1rem); } @@ -2489,9 +2486,6 @@ .align-middle { vertical-align: middle; } - .align-top { - vertical-align: top; - } .font-mono { font-family: var(--font-mono); } @@ -2754,9 +2748,6 @@ .text-white { color: var(--color-white); } - .text-yellow-300 { - color: var(--color-yellow-300); - } .lowercase { text-transform: lowercase; } @@ -2904,106 +2895,6 @@ .ring-inset { --tw-ring-inset: inset; } - .group-hover\:absolute { - &:is(:where(.group):hover *) { - @media (hover: hover) { - position: absolute; - } - } - } - .group-hover\:-top-8 { - &:is(:where(.group):hover *) { - @media (hover: hover) { - top: calc(var(--spacing) * -8); - } - } - } - .group-hover\:-left-6 { - &:is(:where(.group):hover *) { - @media (hover: hover) { - left: calc(var(--spacing) * -6); - } - } - } - .group-hover\:max-w-none { - &:is(:where(.group):hover *) { - @media (hover: hover) { - max-width: none; - } - } - } - .group-hover\:min-w-60 { - &:is(:where(.group):hover *) { - @media (hover: hover) { - min-width: calc(var(--spacing) * 60); - } - } - } - .group-hover\:rounded-xs { - &:is(:where(.group):hover *) { - @media (hover: hover) { - border-radius: var(--radius-xs); - } - } - } - .group-hover\:bg-purple-600 { - &:is(:where(.group):hover *) { - @media (hover: hover) { - background-color: var(--color-purple-600); - } - } - } - .group-hover\:px-6 { - &:is(:where(.group):hover *) { - @media (hover: hover) { - padding-inline: calc(var(--spacing) * 6); - } - } - } - .group-hover\:py-3\.5 { - &:is(:where(.group):hover *) { - @media (hover: hover) { - padding-block: calc(var(--spacing) * 3.5); - } - } - } - .group-hover\:text-purple-100 { - &:is(:where(.group):hover *) { - @media (hover: hover) { - color: var(--color-purple-100); - } - } - } - .group-hover\:decoration-purple-900 { - &:is(:where(.group):hover *) { - @media (hover: hover) { - text-decoration-color: var(--color-purple-900); - } - } - } - .group-hover\:outline-4 { - &:is(:where(.group):hover *) { - @media (hover: hover) { - outline-style: var(--tw-outline-style); - outline-width: 4px; - } - } - } - .group-hover\:outline-purple-400 { - &:is(:where(.group):hover *) { - @media (hover: hover) { - outline-color: var(--color-purple-400); - } - } - } - .group-hover\:outline-dashed { - &:is(:where(.group):hover *) { - @media (hover: hover) { - --tw-outline-style: dashed; - outline-style: dashed; - } - } - } .group-data-\[search-select-highlighted\]\:border-white { &:is(:where(.group)[data-search-select-highlighted] *) { border-color: var(--color-white); @@ -3487,11 +3378,6 @@ outline-color: var(--color-brand-strong); } } - .sm\:table-cell { - @media (width >= 40rem) { - display: table-cell; - } - } .sm\:max-w-\(--breakpoint-sm\) { @media (width >= 40rem) { max-width: var(--breakpoint-sm); @@ -3647,11 +3533,6 @@ } } } - .lg\:table-cell { - @media (width >= 64rem) { - display: table-cell; - } - } .lg\:max-w-3xl { @media (width >= 64rem) { max-width: var(--container-3xl); @@ -4242,6 +4123,11 @@ } } } + .\[\&\.invisible\]\:hidden { + &.invisible { + display: none; + } + } .\[\&\:first-of-type_button\]\:rounded-s-lg { &:first-of-type button { border-start-start-radius: var(--radius-lg);