diff --git a/common/components/domain.py b/common/components/domain.py
index f4534a3..0e5d598 100644
--- a/common/components/domain.py
+++ b/common/components/domain.py
@@ -209,55 +209,56 @@ def PurchasePrice(purchase) -> Node:
)
-def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
- """Alpine.js dropdown to change a game's status."""
- options_html = "\n".join(
- f""
- f"{GameStatus(status=value, children=[label], display='flex')}"
- f""
- for value, label in game_statuses
- )
- list_items = "\n".join(
- f"
"
- f"{GameStatus(status=value, children=[label], display='flex', class_='text-slate-300')}"
- f""
- for value, label in game_statuses
- )
+_SELECTOR_MENU_CLASS = (
+ "absolute top-[105%] left-0 w-full whitespace-nowrap z-10 text-sm "
+ "font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-t-none "
+ "border border-gray-200 dark:border-gray-700"
+)
+_SELECTOR_TOGGLE_CLASS = (
+ "relative px-4 py-2 text-sm font-medium bg-white border border-gray-200 "
+ "rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 "
+ "dark:hover:text-white dark:hover:bg-gray-700 hover:cursor-pointer"
+)
- return Safe(f"""
-
- {_dropdown_button_html(options_html, list_items)}
-
-""")
+
+def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
+ """Light-DOM custom element; behavior in ts/elements/game-status-selector.ts."""
+ from common.components import custom_element
+ from common.components.core import Element
+ from common.components.custom_elements import GameStatusSelectorProps
+ from common.components.primitives import Li, Ul
+
+ options = [
+ Li()[
+ Element(
+ "button",
+ [("type", "button"), ("data-option", ""), ("data-value", str(value))],
+ GameStatus(status=value, children=[label], display="flex"),
+ )
+ ]
+ for value, label in game_statuses
+ ]
+ current_label = Span(data_label="")[
+ GameStatus(
+ status=game.status,
+ children=[game.get_status_display()],
+ display="flex",
+ )
+ ]
+ toggle = Element(
+ "button",
+ [("type", "button"), ("data-toggle", ""), ("class", _SELECTOR_TOGGLE_CLASS)],
+ [current_label, Icon("arrowdown")],
+ )
+ menu = Div(data_menu="", hidden=True, class_=_SELECTOR_MENU_CLASS)[Ul()[*options]]
+ dropdown = Div(
+ data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative"
+ )[toggle, menu]
+ return custom_element(
+ "game-status-selector",
+ GameStatusSelectorProps(game_id=game.id, status=game.status, csrf=csrf_token),
+ children=[Div(class_="flex gap-2 items-center")[dropdown]],
+ )
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node:
diff --git a/common/components/primitives.py b/common/components/primitives.py
index b43fa29..24c551c 100644
--- a/common/components/primitives.py
+++ b/common/components/primitives.py
@@ -25,6 +25,7 @@ from common.components.core import (
Safe,
as_attributes,
as_children,
+ collect_media,
randomid,
)
from common.icons import get_icon
@@ -988,15 +989,26 @@ def SimpleTable(
columns = columns or []
rows = rows or []
+ # Rows/header are stringified into the table markup, so their components'
+ # declared Media would be lost; collect it from the nodes first and attach
+ # it to the returned node so Page() still emits each cell component's JS
+ # (e.g. a in a cell).
+ media = Media()
+
header_html = ""
if header_action:
- header_html = str(TableHeader(children=[header_action]))
+ header_node = TableHeader(children=[header_action])
+ header_html = str(header_node)
+ media = media + collect_media(header_node)
columns_html = "".join(
f'{conditional_escape(col)} | '
for col in columns
)
- rows_html = "".join(str(TableRow(data=row)) for row in rows)
+ row_nodes = [TableRow(data=row) for row in rows]
+ rows_html = "".join(str(node) for node in row_nodes)
+ for node in row_nodes:
+ media = media + collect_media(node)
pagination_html = ""
if page_obj and elided_page_range:
@@ -1012,7 +1024,8 @@ def SimpleTable(
f"{columns_html}
"
''
f"{rows_html}"
- f"{pagination_html}"
+ f"{pagination_html}",
+ media=media,
)
diff --git a/e2e/test_custom_elements_e2e.py b/e2e/test_custom_elements_e2e.py
new file mode 100644
index 0000000..1fbf2da
--- /dev/null
+++ b/e2e/test_custom_elements_e2e.py
@@ -0,0 +1,34 @@
+import pytest
+from django.urls import reverse
+from playwright.sync_api import Page, expect
+
+
+@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('input[type="submit"]')
+ page.wait_for_url(f"{live_server.url}/tracker**")
+ return page
+
+
+@pytest.mark.django_db
+def test_game_status_selector_opens_and_patches(authenticated_page: Page, live_server):
+ from games.models import Game, Platform
+
+ platform = Platform.objects.create(name="PC", icon="pc")
+ game = Game.objects.create(name="Test Game", platform=platform, status="u")
+
+ page = authenticated_page
+ page.goto(f"{live_server.url}{reverse('games:list_games')}")
+
+ host = page.locator("game-status-selector").first
+ expect(host).to_be_attached()
+ host.locator("[data-toggle]").click()
+ expect(host.locator("[data-menu]")).to_be_visible()
+ host.locator('[data-option][data-value="f"]').click()
+ expect(host.locator("[data-menu]")).to_be_hidden()
+ game.refresh_from_db()
+ assert game.status == "f"
diff --git a/tests/test_custom_elements.py b/tests/test_custom_elements.py
index ff77e2b..b2e26d8 100644
--- a/tests/test_custom_elements.py
+++ b/tests/test_custom_elements.py
@@ -56,3 +56,22 @@ class RegistryTest(unittest.TestCase):
register_element("x-reg-test", "XRegTest", SampleProps)
self.assertEqual(len(ELEMENT_REGISTRY), before + 1)
self.assertEqual(ELEMENT_REGISTRY[-1].tag, "x-reg-test")
+
+
+class GameStatusSelectorRenderTest(unittest.TestCase):
+ def test_emits_tag_props_and_media(self):
+ from types import SimpleNamespace
+
+ from common.components import GameStatusSelector, collect_media, render
+
+ game = SimpleNamespace(id=7, status="f", get_status_display=lambda: "Finished")
+ node = GameStatusSelector(game, [("u", "Unplayed"), ("f", "Finished")], "tok")
+ html = render(node)
+ self.assertIn("("[data-toggle]");
+ const menu = host.querySelector("[data-menu]");
+ const label = host.querySelector("[data-label]");
+ if (!toggle || !menu || !label) return;
+
+ const close = () => {
+ menu.hidden = true;
+ };
+
+ toggle.addEventListener("click", (event) => {
+ event.stopPropagation();
+ menu.hidden = !menu.hidden;
+ });
+ document.addEventListener("click", (event) => {
+ if (!host.contains(event.target as Node)) close();
+ });
+
+ host.querySelectorAll("[data-option]").forEach((option) => {
+ option.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ const raw = option.dataset.value ?? "";
+ label.innerHTML = option.innerHTML;
+ close();
+ const body: Record = {
+ [config.bodyKey]: config.numericValue ? Number(raw) : raw,
+ };
+ window
+ .fetchWithHtmxTriggers(config.patchUrl, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json", "X-CSRFToken": config.csrf },
+ body: JSON.stringify(body),
+ })
+ .then(() => document.body.dispatchEvent(new CustomEvent(config.event)))
+ .catch(() => console.error("Failed to update", config.patchUrl));
+ });
+ });
+}
diff --git a/ts/elements/game-status-selector.ts b/ts/elements/game-status-selector.ts
new file mode 100644
index 0000000..951f6fe
--- /dev/null
+++ b/ts/elements/game-status-selector.ts
@@ -0,0 +1,16 @@
+import { readGameStatusSelectorProps } from "../generated/props.js";
+import { initDropdown } from "./dropdown.js";
+
+class GameStatusSelectorElement extends HTMLElement {
+ connectedCallback(): void {
+ const props = readGameStatusSelectorProps(this);
+ initDropdown(this, {
+ patchUrl: `/api/games/${props.gameId}/status`,
+ bodyKey: "status",
+ event: "status-changed",
+ csrf: props.csrf,
+ });
+ }
+}
+
+customElements.define("game-status-selector", GameStatusSelectorElement);