diff --git a/e2e/test_dropdown_clipping_e2e.py b/e2e/test_dropdown_clipping_e2e.py new file mode 100644 index 0000000..1b16108 --- /dev/null +++ b/e2e/test_dropdown_clipping_e2e.py @@ -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] diff --git a/ts/elements/dropdown.ts b/ts/elements/dropdown.ts index 3d6880a..9c066fd 100644 --- a/ts/elements/dropdown.ts +++ b/ts/elements/dropdown.ts @@ -15,13 +15,71 @@ export function initDropdown(host: HTMLElement, config: DropdownConfig): void { const label = host.querySelector("[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();