diff --git a/common/components/__init__.py b/common/components/__init__.py
index 6e8795e..10e4bfb 100644
--- a/common/components/__init__.py
+++ b/common/components/__init__.py
@@ -18,7 +18,7 @@ from common.components.core import (
randomid,
render,
)
-from common.components.custom_elements import custom_element, register_element
+from common.components.custom_elements import SessionTimestampButtons, register_element
from common.components.date_range_picker import (
DateRangeCalendar,
DateRangeField,
@@ -77,6 +77,7 @@ from common.components.primitives import (
Tr,
Ul,
YearPicker,
+ custom_element_builder,
paginated_table_content,
)
from common.components.search_select import (
@@ -92,8 +93,9 @@ from common.utils import truncate
__all__ = [
"truncate",
"BaseComponent",
- "custom_element",
"register_element",
+ "SessionTimestampButtons",
+ "custom_element_builder",
"Element",
"Fragment",
"Media",
diff --git a/common/components/custom_elements.py b/common/components/custom_elements.py
index 0b42c7c..1ae485a 100644
--- a/common/components/custom_elements.py
+++ b/common/components/custom_elements.py
@@ -9,9 +9,10 @@ reader so drift fails ``tsc``.
"""
from dataclasses import dataclass
-from typing import Mapping, TypedDict, get_type_hints
+from typing import TypedDict, get_type_hints
-from common.components.core import Children, Element, HTMLAttribute, Media, Node
+from common.components.core import Media
+from common.components.primitives import custom_element_builder
@dataclass(frozen=True)
@@ -33,22 +34,6 @@ def _kebab(name: str) -> str:
return name.replace("_", "-")
-def custom_element(
- tag: str, props: Mapping[str, object], *, children: Children = None
-) -> Node:
- """Emit ``children`` and declare its compiled module.
-
- The module path mirrors the source layout: ``ts/elements/.ts`` compiles
- to ``dist/elements/.js``, which ``Media`` loads via ``ModuleScript``."""
- attributes: list[HTMLAttribute] = [
- (_kebab(key), value) # type: ignore[misc]
- for key, value in props.items()
- ]
- return Element(tag, attributes, children).with_media(
- Media(js=(f"dist/elements/{tag}.js",))
- )
-
-
# ── Codegen ──────────────────────────────────────────────────────────────────
_TYPE_MAP = {int: "number", float: "number", str: "string", bool: "boolean"}
@@ -121,3 +106,22 @@ class PlayEventRowProps(TypedDict):
register_element("play-event-row", "PlayEventRow", PlayEventRowProps)
+
+
+class SessionTimestampButtonsProps(TypedDict):
+ pass
+
+
+register_element(
+ "session-timestamp-buttons", "SessionTimestampButtons", SessionTimestampButtonsProps
+)
+
+
+# ── Named tag builders (consistent htpy-style with Div/Span) ─────────────────
+# Underscore-prefixed: used internally by domain wrappers.
+# Public ones (no domain wrapper): exported directly.
+
+_GameStatusSelector = custom_element_builder("game-status-selector")
+_SessionDeviceSelector = custom_element_builder("session-device-selector")
+_PlayEventRow = custom_element_builder("play-event-row")
+SessionTimestampButtons = custom_element_builder("session-timestamp-buttons")
diff --git a/common/components/domain.py b/common/components/domain.py
index e4e0873..68fa94b 100644
--- a/common/components/domain.py
+++ b/common/components/domain.py
@@ -228,9 +228,8 @@ _SELECTOR_OPTION_CLASS = (
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.custom_elements import _GameStatusSelector, GameStatusSelectorProps
from common.components.primitives import Li, Ul
options = [
@@ -266,18 +265,15 @@ def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
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]],
- )
+ return _GameStatusSelector(game_id=game.id, status=game.status, csrf=csrf_token)[
+ Div(class_="flex gap-2 items-center")[dropdown]
+ ]
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node:
"""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.custom_elements import _SessionDeviceSelector, SessionDeviceSelectorProps
from common.components.primitives import Li, Ul
current_name = session.device.name if session.device else "Unknown"
@@ -307,8 +303,6 @@ def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node:
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]],
- )
+ return _SessionDeviceSelector(session_id=session.id, csrf=csrf_token)[
+ Div(class_="flex gap-2 items-center")[dropdown]
+ ]
diff --git a/common/components/primitives.py b/common/components/primitives.py
index 24c551c..808e5e9 100644
--- a/common/components/primitives.py
+++ b/common/components/primitives.py
@@ -68,8 +68,21 @@ def _attrs_from_kwargs(attrs: dict[str, object]) -> list[HTMLAttribute]:
return result
-def _html_element(tag_name: str):
- """Build a generic element builder for ``tag_name`` (the whitelist factory)."""
+def custom_element_builder(tag_name: str):
+ """Create a tag builder for a custom element with auto-attached Media.
+
+ The module path follows the convention ``ts/elements/.ts`` →
+ ``dist/elements/.js``.
+ """
+ return _html_element(tag_name, Media(js=(f"dist/elements/{tag_name}.js",)))
+
+
+def _html_element(tag_name: str, media: Media | None = None):
+ """Build a generic element builder for ``tag_name`` (the whitelist factory).
+
+ If ``media`` is provided, every node created by the builder will carry it
+ (used for custom elements whose compiled JS must be loaded automatically).
+ """
def element(
attributes: Attributes | None = None,
@@ -77,7 +90,8 @@ def _html_element(tag_name: str):
**attrs: object,
) -> Element:
merged = as_attributes(attributes) + _attrs_from_kwargs(attrs)
- return Element(tag_name, merged, children)
+ node = Element(tag_name, merged, children)
+ return node.with_media(media) if media else node
element.__name__ = element.__qualname__ = tag_name[:1].upper() + tag_name[1:]
element.__doc__ = f"Builder for the <{tag_name}> element."
@@ -245,8 +259,9 @@ def Button(
title: str = "",
onclick: str = "",
name: str = "",
+ **attrs: object,
) -> Element:
- attributes = as_attributes(attributes)
+ attributes = as_attributes(attributes) + _attrs_from_kwargs(attrs)
children = children or []
# Separate custom class from other generic attributes
diff --git a/games/static/js/add_session.js b/games/static/js/add_session.js
deleted file mode 100644
index 8effc41..0000000
--- a/games/static/js/add_session.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import { toISOUTCString } from "./utils.js";
-
-for (let button of document.querySelectorAll("[data-target]")) {
- let target = button.getAttribute("data-target");
- let type = button.getAttribute("data-type");
- let targetElement = document.querySelector(`#id_${target}`);
- button.addEventListener("click", (event) => {
- event.preventDefault();
- if (type == "now") {
- targetElement.value = toISOUTCString(new Date());
- } else if (type == "copy") {
- const oppositeName =
- targetElement.name == "timestamp_start"
- ? "timestamp_end"
- : "timestamp_start";
- document.querySelector(`[name='${oppositeName}']`).value =
- targetElement.value;
- } else if (type == "toggle") {
- if (targetElement.type == "datetime-local") targetElement.type = "text";
- else targetElement.type = "datetime-local";
- }
- });
-}
diff --git a/games/static/js/year_picker.js b/games/static/js/year_picker.js
new file mode 100644
index 0000000..7eeb843
--- /dev/null
+++ b/games/static/js/year_picker.js
@@ -0,0 +1,38 @@
+import { onSwap } from "./utils.js";
+
+onSwap("#year-picker-input", function(pickerEl) {
+ const selectedYear = pickerEl.dataset.selectedYear;
+ const urlTemplate = pickerEl.dataset.urlTemplate;
+ const currentYear = new Date().getFullYear();
+ const availableYears = new Set(
+ pickerEl.dataset.availableYears
+ .split(",")
+ .map(s => parseInt(s.trim()))
+ .filter(n => !isNaN(n))
+ );
+
+ const picker = new Datepicker(pickerEl, {
+ pickLevel: 2,
+ format: "yyyy",
+ minDate: new Date(1999, 0, 1),
+ maxDate: new Date(currentYear, 11, 31),
+ autohide: false,
+ orientation: "bottom end",
+ showOnClick: false,
+ showOnFocus: false,
+ beforeShowYear: (date) => ({ enabled: availableYears.has(date.getFullYear()) }),
+ });
+ pickerEl._pickerInstance = picker;
+
+ picker.element.addEventListener("changeDate", (event) => {
+ const year = event.detail.date?.getFullYear();
+ if (year && urlTemplate) {
+ window.location.href = urlTemplate.replace("__year__", year);
+ }
+ });
+
+ if (selectedYear) {
+ picker.dates = [new Date(parseInt(selectedYear), 0, 1)];
+ picker.update();
+ }
+});
diff --git a/games/views/game.py b/games/views/game.py
index 618f6a2..82a5917 100644
--- a/games/views/game.py
+++ b/games/views/game.py
@@ -354,7 +354,7 @@ _PLAYED_MENU = (
def _played_row(game: Game, request: HttpRequest) -> Node:
"""'Played N times' control as a custom element (ts/elements/play-event-row.ts)."""
from common.components import Element, custom_element
- from common.components.custom_elements import PlayEventRowProps
+ from common.components.custom_elements import PlayEventRowProps, _PlayEventRow
played = game.playevents.count()
@@ -397,19 +397,13 @@ def _played_row(game: Game, request: HttpRequest) -> Node:
group = Div(class_="inline-flex items-stretch rounded-md shadow-2xs")[
count_button, toggle_group
]
- return custom_element(
- "play-event-row",
- PlayEventRowProps(
- game_id=game.id,
- csrf=get_token(request),
- api_create_url=reverse("api-1.0.0:create_playevent"),
- ),
- children=[
- Div(class_="flex gap-2 items-center")[
- Span(class_="uppercase")["Played"], group
- ]
- ],
- )
+ return _PlayEventRow(
+ game_id=game.id,
+ csrf=get_token(request),
+ api_create_url=reverse("api-1.0.0:create_playevent"),
+ )[Div(class_="flex gap-2 items-center")[
+ Span(class_="uppercase")["Played"], group
+ ]]
def _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> SafeText:
diff --git a/games/views/session.py b/games/views/session.py
index a00cd25..64d140b 100644
--- a/games/views/session.py
+++ b/games/views/session.py
@@ -11,12 +11,12 @@ from django.utils import timezone
from django.utils.safestring import SafeText, mark_safe
from common.components import (
- Fragment,
A,
AddForm,
Button,
ButtonGroup,
Div,
+ Fragment,
Icon,
ModuleScript,
NameWithIcon,
@@ -27,6 +27,7 @@ from common.components import (
SessionDeviceSelector,
paginated_table_content,
)
+from common.components import SessionTimestampButtons
from common.components.primitives import Span, Td, Tr
from common.layout import render_page
from common.time import (
@@ -208,32 +209,20 @@ def _session_fields(form) -> Fragment:
this_side = "start" if field.name == "timestamp_start" else "end"
other_side = "end" if field.name == "timestamp_start" else "start"
children.append(
- Span(
- attributes=[
- (
- "class",
- "form-row-button-group flex-row gap-3 justify-start mt-3",
- ),
- ("hx-boost", "false"),
+ SessionTimestampButtons(
+ class_="form-row-button-group flex-row gap-3 justify-start mt-3",
+ hx_boost="false",
+ )[
+ Button(data_target=field.name, data_type="now", size="xs")[
+ "Set to now"
],
- children=[
- Button(
- [("data-target", field.name), ("data-type", "now")],
- "Set to now",
- size="xs",
- ),
- Button(
- [("data-target", field.name), ("data-type", "toggle")],
- "Toggle text",
- size="xs",
- ),
- Button(
- [("data-target", field.name), ("data-type", "copy")],
- f"Copy {this_side} value to {other_side}",
- size="xs",
- ),
+ Button(data_target=field.name, data_type="toggle", size="xs")[
+ "Toggle text"
],
- )
+ Button(data_target=field.name, data_type="copy", size="xs")[
+ f"Copy {this_side} value to {other_side}"
+ ],
+ ]
)
rows.append(Div(children=children))
return Fragment(*rows, separator="\n")
@@ -265,9 +254,7 @@ def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
request,
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
title="Add New Session",
- scripts=mark_safe(
- ModuleScript("search_select.js") + ModuleScript("add_session.js")
- ),
+ scripts=mark_safe(ModuleScript("search_select.js")),
)
@@ -282,9 +269,7 @@ def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
request,
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
title="Edit Session",
- scripts=mark_safe(
- ModuleScript("search_select.js") + ModuleScript("add_session.js")
- ),
+ scripts=mark_safe(ModuleScript("search_select.js")),
)
diff --git a/tests/test_custom_elements.py b/tests/test_custom_elements.py
index 23710dc..21c4df7 100644
--- a/tests/test_custom_elements.py
+++ b/tests/test_custom_elements.py
@@ -1,7 +1,7 @@
import unittest
from typing import TypedDict
-from common.components import custom_element, render
+from common.components import custom_element_builder, render
from common.components.custom_elements import (
ElementSpec,
_ts_for_spec,
@@ -17,9 +17,8 @@ class SampleProps(TypedDict):
class CustomElementBuilderTest(unittest.TestCase):
def test_serializes_props_to_kebab_attributes(self):
- html = render(
- custom_element("x-sample", {"game_id": 3, "status": "f"}, children=["hi"])
- )
+ x_sample = custom_element_builder("x-sample")
+ html = render(x_sample(game_id=3, status="f")["hi"])
self.assertIn(" {
+ event.preventDefault();
+ if (type == "now") {
+ targetElement.value = toISOUTCString(new Date());
+ } else if (type == "copy") {
+ const oppositeName =
+ targetElement.name == "timestamp_start"
+ ? "timestamp_end"
+ : "timestamp_start";
+ const opposite = document.querySelector(`[name='${oppositeName}']`);
+ if (!(opposite instanceof HTMLInputElement)) return;
+ opposite.value = targetElement.value;
+ } else if (type == "toggle") {
+ if (targetElement.type == "datetime-local") targetElement.type = "text";
+ else targetElement.type = "datetime-local";
+ }
+ });
+ }
+ }
+}
+
+customElements.define("session-timestamp-buttons", SessionTimestampButtonsElement);