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
70 changes: 70 additions & 0 deletions e2e/test_dropdown_clipping_e2e.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Browser test: the device dropdown is not clipped by the table wrapper (#39).

The session list lives inside an ``overflow-x-auto`` wrapper, which forces
``overflow-y: auto`` and used to clip an absolutely-positioned dropdown menu
that extended past a short table. The menu now opens with ``position: fixed``
so it escapes the clipping ancestor and stays within the viewport.
"""

import pytest
from django.urls import reverse
from django.utils import timezone
from playwright.sync_api import Page

from games.models import Device, Game, Platform, Session


@pytest.fixture
def authenticated_page(live_server, page: Page, django_user_model) -> Page:
django_user_model.objects.create_user(username="tester", password="secret123")
page.goto(f"{live_server.url}{reverse('login')}")
page.fill('input[name="username"]', "tester")
page.fill('input[name="password"]', "secret123")
page.click('button:has-text("Login")')
page.wait_for_url(f"{live_server.url}/tracker**")
return page


def test_device_dropdown_not_clipped_on_short_table(
authenticated_page: Page, live_server
):
page = authenticated_page
page.set_viewport_size({"width": 1280, "height": 800})
platform = Platform.objects.create(name="PC", icon="pc", group="PC")
game = Game.objects.create(name="Tunic")
game.platform = platform
game.save()
# Many devices → a tall menu; a single row → a short table that would clip
# an absolutely-positioned menu.
devices = [Device.objects.create(name=f"Device {i:02d}") for i in range(15)]
session = Session.objects.create(
game=game, device=devices[0], timestamp_start=timezone.now()
)

page.goto(f"{live_server.url}{reverse('games:list_sessions')}")
page.locator(f"#session-row-{session.pk} [data-toggle]").click()

menu = page.locator("[data-menu]:not([hidden])")
menu.wait_for(state="visible")

geometry = page.evaluate(
"""() => {
const menu = document.querySelector('[data-menu]:not([hidden])');
const rect = menu.getBoundingClientRect();
return {
position: getComputedStyle(menu).position,
bottom: rect.bottom,
viewportHeight: window.innerHeight,
};
}"""
)
# Fixed positioning escapes the overflow-x-auto clip...
assert geometry["position"] == "fixed"
# ...and the menu stays inside the viewport (not clipped/cut off).
assert geometry["bottom"] <= geometry["viewportHeight"] + 1, geometry

# A device far down the (previously clipped) list is selectable.
page.locator("[data-option]", has_text="Device 14").click()
page.wait_for_timeout(200)
session.refresh_from_db()
assert session.device == devices[14]
62 changes: 60 additions & 2 deletions ts/elements/dropdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,71 @@ export function initDropdown(host: HTMLElement, config: DropdownConfig): void {
const label = host.querySelector<HTMLElement>("[data-label]");
if (!toggle || !menu || !label) return;

const close = () => {
// The menu lives inside the table's `overflow-x-auto` wrapper, which forces
// `overflow-y: auto` and clips an absolutely-positioned menu that extends
// past a short table (issue #39). Position it `fixed` while open so it
// escapes the clipping ancestor, anchored to the toggle and bounded to the
// viewport (flipping up when there is more room above).
const VIEWPORT_MARGIN = 8;

const positionMenu = (): void => {
const rect = toggle.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom - VIEWPORT_MARGIN;
const spaceAbove = rect.top - VIEWPORT_MARGIN;
const openUp = menu.scrollHeight > spaceBelow && spaceAbove > spaceBelow;

menu.style.position = "fixed";
menu.style.left = `${rect.left}px`;
menu.style.width = `${rect.width}px`;
menu.style.maxHeight = `${Math.max(0, openUp ? spaceAbove : spaceBelow)}px`;
menu.style.overflowY = "auto";
if (openUp) {
menu.style.top = "";
menu.style.bottom = `${window.innerHeight - rect.top}px`;
} else {
menu.style.bottom = "";
menu.style.top = `${rect.bottom}px`;
}
};

const clearPosition = (): void => {
for (const property of [
"position",
"top",
"bottom",
"left",
"width",
"max-height",
"overflow-y",
]) {
menu.style.removeProperty(property);
}
};

const reposition = (): void => {
if (!menu.hidden) positionMenu();
};

const open = (): void => {
menu.hidden = false;
positionMenu();
// Capture-phase scroll listener so scrolling any ancestor (incl. the table
// wrapper) keeps the fixed menu anchored to its toggle.
window.addEventListener("scroll", reposition, true);
window.addEventListener("resize", reposition);
};

const close = (): void => {
menu.hidden = true;
clearPosition();
window.removeEventListener("scroll", reposition, true);
window.removeEventListener("resize", reposition);
};

toggle.addEventListener("click", (event) => {
event.stopPropagation();
menu.hidden = !menu.hidden;
if (menu.hidden) open();
else close();
});
document.addEventListener("click", (event) => {
if (!host.contains(event.target as Node)) close();
Expand Down
Loading