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
+10 -4
View File
@@ -138,10 +138,16 @@ def _popover_html(
) )
popover_tooltip_class = ( popover_tooltip_class = (
"absolute z-10 invisible inline-block text-sm text-white " # `[&.invisible]:hidden`: while Flowbite keeps the popover hidden it
"transition-opacity duration-300 bg-white border border-purple-200 " # carries the `invisible` class (visibility:hidden), which still
"rounded-lg shadow-xs opacity-0 dark:text-white dark:border-purple-600 " # occupies layout — an absolutely-positioned, Popper-transformed
"dark:bg-purple-800" # 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( div = Div(
+55
View File
@@ -46,3 +46,58 @@ def test_finish_session_swaps_row_in_place(authenticated_page: Page, live_server
session.refresh_from_db() session.refresh_from_db()
assert session.timestamp_end is not None 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"
+5 -119
View File
@@ -1487,9 +1487,6 @@
.h-10 { .h-10 {
height: calc(var(--spacing) * 10); height: calc(var(--spacing) * 10);
} }
.h-12 {
height: calc(var(--spacing) * 12);
}
.h-\[calc\(100\%-1rem\)\] { .h-\[calc\(100\%-1rem\)\] {
height: calc(100% - 1rem); height: calc(100% - 1rem);
} }
@@ -2489,9 +2486,6 @@
.align-middle { .align-middle {
vertical-align: middle; vertical-align: middle;
} }
.align-top {
vertical-align: top;
}
.font-mono { .font-mono {
font-family: var(--font-mono); font-family: var(--font-mono);
} }
@@ -2754,9 +2748,6 @@
.text-white { .text-white {
color: var(--color-white); color: var(--color-white);
} }
.text-yellow-300 {
color: var(--color-yellow-300);
}
.lowercase { .lowercase {
text-transform: lowercase; text-transform: lowercase;
} }
@@ -2904,106 +2895,6 @@
.ring-inset { .ring-inset {
--tw-ring-inset: 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 { .group-data-\[search-select-highlighted\]\:border-white {
&:is(:where(.group)[data-search-select-highlighted] *) { &:is(:where(.group)[data-search-select-highlighted] *) {
border-color: var(--color-white); border-color: var(--color-white);
@@ -3487,11 +3378,6 @@
outline-color: var(--color-brand-strong); outline-color: var(--color-brand-strong);
} }
} }
.sm\:table-cell {
@media (width >= 40rem) {
display: table-cell;
}
}
.sm\:max-w-\(--breakpoint-sm\) { .sm\:max-w-\(--breakpoint-sm\) {
@media (width >= 40rem) { @media (width >= 40rem) {
max-width: var(--breakpoint-sm); max-width: var(--breakpoint-sm);
@@ -3647,11 +3533,6 @@
} }
} }
} }
.lg\:table-cell {
@media (width >= 64rem) {
display: table-cell;
}
}
.lg\:max-w-3xl { .lg\:max-w-3xl {
@media (width >= 64rem) { @media (width >= 64rem) {
max-width: var(--container-3xl); 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\]\:rounded-s-lg {
&:first-of-type button { &:first-of-type button {
border-start-start-radius: var(--radius-lg); border-start-start-radius: var(--radius-lg);