diff --git a/common/components/custom_elements.py b/common/components/custom_elements.py
index 61eec33..48a95ae 100644
--- a/common/components/custom_elements.py
+++ b/common/components/custom_elements.py
@@ -102,3 +102,13 @@ class GameStatusSelectorProps(TypedDict):
register_element("game-status-selector", "GameStatusSelector", GameStatusSelectorProps)
+
+
+class SessionDeviceSelectorProps(TypedDict):
+ session_id: int
+ csrf: str
+
+
+register_element(
+ "session-device-selector", "SessionDeviceSelector", SessionDeviceSelectorProps
+)
diff --git a/common/components/domain.py b/common/components/domain.py
index 0e5d598..3baeadb 100644
--- a/common/components/domain.py
+++ b/common/components/domain.py
@@ -262,79 +262,38 @@ def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node:
- """Alpine.js dropdown to change a session's device."""
- device_id = session.device_id or "null"
- device_name = (session.device.name if session.device else "Unknown").replace(
- "'", "\\'"
+ """Light-DOM custom element; behavior in ts/elements/session-device-selector.ts."""
+ from common.components import custom_element
+ from common.components.core import Element
+ from common.components.custom_elements import SessionDeviceSelectorProps
+ from common.components.primitives import Li, Ul
+
+ current_name = session.device.name if session.device else "Unknown"
+ options = [
+ Li()[
+ Element(
+ "button",
+ [
+ ("type", "button"),
+ ("data-option", ""),
+ ("data-value", str(device.id)),
+ ],
+ children=[device.name],
+ )
+ ]
+ for device in session_devices
+ ]
+ toggle = Element(
+ "button",
+ [("type", "button"), ("data-toggle", ""), ("class", _SELECTOR_TOGGLE_CLASS)],
+ [Span(data_label="")[current_name], Icon("arrowdown")],
)
-
- list_items = "\n".join(
- f'
{d.name}"
- for d in session_devices
- )
-
- return Safe(f"""
-
- {
- _dropdown_button_html(
- '' + str(Icon("arrowdown")), list_items
- )
- }
-
-""")
-
-
-def _dropdown_button_html(button_content: str, list_items: str) -> str:
- """Shared dropdown button + list structure for Alpine.js selectors."""
- return (
- ''
- '
"
- "
"
+ 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(
+ "session-device-selector",
+ SessionDeviceSelectorProps(session_id=session.id, csrf=csrf_token),
+ children=[Div(class_="flex gap-2 items-center")[dropdown]],
)
diff --git a/e2e/test_custom_elements_e2e.py b/e2e/test_custom_elements_e2e.py
index 1fbf2da..c5c8782 100644
--- a/e2e/test_custom_elements_e2e.py
+++ b/e2e/test_custom_elements_e2e.py
@@ -28,7 +28,36 @@ def test_game_status_selector_opens_and_patches(authenticated_page: Page, live_s
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()
+ with page.expect_response(
+ lambda r: "/status" in r.url and r.request.method == "PATCH"
+ ):
+ host.locator('[data-option][data-value="f"]').click()
expect(host.locator("[data-menu]")).to_be_hidden()
game.refresh_from_db()
assert game.status == "f"
+
+
+@pytest.mark.django_db
+def test_session_device_selector_patches(authenticated_page: Page, live_server):
+ from games.models import Device, Game, Platform, Session
+
+ platform = Platform.objects.create(name="PC", icon="pc")
+ game = Game.objects.create(name="Test Game", platform=platform)
+ desktop = Device.objects.create(name="Desktop")
+ deck = Device.objects.create(name="Deck")
+ session = Session.objects.create(
+ game=game, device=desktop, timestamp_start="2025-01-01 00:00:00+00:00"
+ )
+
+ page = authenticated_page
+ page.goto(f"{live_server.url}{reverse('games:list_sessions')}")
+
+ host = page.locator("session-device-selector").first
+ expect(host).to_be_attached()
+ host.locator("[data-toggle]").click()
+ with page.expect_response(
+ lambda r: "/device" in r.url and r.request.method == "PATCH"
+ ):
+ host.locator(f'[data-option][data-value="{deck.id}"]').click()
+ session.refresh_from_db()
+ assert session.device_id == deck.id
diff --git a/tests/test_custom_elements.py b/tests/test_custom_elements.py
index b2e26d8..23710dc 100644
--- a/tests/test_custom_elements.py
+++ b/tests/test_custom_elements.py
@@ -75,3 +75,21 @@ class GameStatusSelectorRenderTest(unittest.TestCase):
self.assertIn('data-value="u"', html)
self.assertNotIn("x-data", html) # no Alpine left
self.assertIn("dist/elements/game-status-selector.js", collect_media(node).js)
+
+
+class SessionDeviceSelectorRenderTest(unittest.TestCase):
+ def test_emits_tag_and_options(self):
+ from types import SimpleNamespace
+
+ from common.components import SessionDeviceSelector, render
+
+ session = SimpleNamespace(id=4, device=SimpleNamespace(name="Desktop"))
+ devices = [
+ SimpleNamespace(id=1, name="Desktop"),
+ SimpleNamespace(id=2, name="Deck"),
+ ]
+ html = render(SessionDeviceSelector(session, devices, "tok"))
+ self.assertIn("