From 3bd14e8c890e9230465c66426ba95685b2ccc0cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sat, 20 Jun 2026 23:14:06 +0200 Subject: [PATCH] fix(dropdown): position menu fixed so it isn't clipped by table wrapper The device/status dropdown menu is absolutely positioned inside the session list's overflow-x-auto wrapper. Because overflow-x:auto forces overflow-y:auto, a menu taller than a short table was clipped (issue #39). Open the menu with position:fixed anchored to its toggle so it escapes the clipping ancestor, bound it to the viewport with an internal scroll, flip it up when there is more room above, and reposition on scroll/resize while open. Fixes #39. Co-Authored-By: Claude Opus 4.8 --- e2e/test_dropdown_clipping_e2e.py | 70 +++++++++++++++++++++++++++++++ ts/elements/dropdown.ts | 62 ++++++++++++++++++++++++++- 2 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 e2e/test_dropdown_clipping_e2e.py 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();