Files
timetracker/common/components/custom_elements.py
T
lukas 82416e149d Convert onSwap widgets to custom elements (issue #18)
Replaces the four onSwap-based widgets with TypeScript custom elements
following the pattern from PR #16. Each widget gets a class extending
HTMLElement with connectedCallback/disconnectedCallback, typed props via
register_element + gen_element_types codegen, and lives in ts/elements/.

- range-slider: RangeSliderElement; Python uses _RangeSlider builder
- date-range-picker: DateRangePickerElement; Python uses _DateRangePicker builder
- search-select: SearchSelectElement; Python uses _SearchSelect builder;
  data-* attrs become plain attrs (data-name -> name, data-search-url -> search-url, etc.)
- filter-bar: FilterBarElement; props carry preset URLs; onclick/onsubmit
  attrs replaced with data-filter-bar-* sentinel attrs; all window.* globals removed

Deletes ts/range_slider.ts, ts/search_select.ts, ts/date_range_picker.ts,
ts/filter_bar.ts. Updates all tests and e2e pages to use the new element
selectors and script paths (dist/elements/<tag>.js).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 14:22:59 +02:00

225 lines
6.8 KiB
Python

"""Custom-element builder, registry, and TypeScript codegen.
A custom element is a light-DOM Web Component: the Python builder emits a
semantic tag whose typed props become kebab-case attributes and whose behavior
lives in a compiled TS module (loaded via Media). One ``TypedDict`` per element
is the single source of truth for the server<->client contract;
``gen_element_types`` turns each registered spec into a TS interface + attribute
reader so drift fails ``tsc``.
"""
from dataclasses import dataclass
from typing import TypedDict, get_type_hints
from common.components.core import Node
from common.components.primitives import (
Div,
Input,
Label,
Template,
custom_element_builder,
)
@dataclass(frozen=True)
class ElementSpec:
tag: str # e.g. "game-status-selector"
ts_name: str # e.g. "GameStatusSelector"
props: type # a TypedDict subclass
ELEMENT_REGISTRY: list[ElementSpec] = []
def register_element(tag: str, ts_name: str, props: type) -> None:
"""Register an element so codegen can emit its TS contract."""
ELEMENT_REGISTRY.append(ElementSpec(tag, ts_name, props))
def _kebab(name: str) -> str:
return name.replace("_", "-")
# ── Codegen ──────────────────────────────────────────────────────────────────
_TYPE_MAP = {int: "number", float: "number", str: "string", bool: "boolean"}
def _camel(name: str) -> str:
head, *tail = name.split("_")
return head + "".join(part.title() for part in tail)
def _reader_expr(name: str, python_type: type) -> str:
attr = _kebab(name)
if python_type in (int, float):
return f'Number(el.getAttribute("{attr}"))'
if python_type is bool:
return f'el.getAttribute("{attr}") === "true"'
return f'el.getAttribute("{attr}") ?? ""'
def _ts_for_spec(spec: ElementSpec) -> str:
hints = get_type_hints(spec.props)
interface_lines = "\n".join(
f" {_camel(name)}: {_TYPE_MAP[python_type]};"
for name, python_type in hints.items()
)
reader_lines = "\n".join(
f" {_camel(name)}: {_reader_expr(name, python_type)},"
for name, python_type in hints.items()
)
return (
f"export interface {spec.ts_name}Props {{\n{interface_lines}\n}}\n\n"
f"export function read{spec.ts_name}Props(el: HTMLElement): "
f"{spec.ts_name}Props {{\n return {{\n{reader_lines}\n }};\n}}"
)
def render_props_module() -> str:
"""The full ``ts/generated/props.ts`` content for every registered element."""
header = "// GENERATED by `manage.py gen_element_types` — do not edit.\n"
blocks = [_ts_for_spec(spec) for spec in ELEMENT_REGISTRY]
return header + "\n" + "\n\n".join(blocks) + "\n"
# ── Element prop schemas (registered at import time) ─────────────────────────
class GameStatusSelectorProps(TypedDict):
game_id: int
status: str
csrf: str
register_element("game-status-selector", "GameStatusSelector", GameStatusSelectorProps)
class SessionDeviceSelectorProps(TypedDict):
session_id: int
csrf: str
register_element(
"session-device-selector", "SessionDeviceSelector", SessionDeviceSelectorProps
)
class PlayEventRowProps(TypedDict):
game_id: int
csrf: str
api_create_url: str
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")
class SelectionFieldsProps(TypedDict):
source: str # data-name of the source SearchSelect to mirror
name_prefix: str # each rendered input is named f"{name_prefix}{item_id}"
field_type: str # input type, e.g. "number"
min_items: int # render nothing until at least this many items are selected
active: bool # when false, render nothing (but preserve typed values)
register_element("selection-fields", "SelectionFields", SelectionFieldsProps)
_SelectionFields = custom_element_builder("selection-fields")
class RangeSliderProps(TypedDict):
min: int
max: int
step: int
mode: str # "range" | "point"
register_element("range-slider", "RangeSlider", RangeSliderProps)
_RangeSlider = custom_element_builder("range-slider")
class DateRangePickerProps(TypedDict):
pass
register_element("date-range-picker", "DateRangePicker", DateRangePickerProps)
_DateRangePicker = custom_element_builder("date-range-picker")
class SearchSelectProps(TypedDict):
name: str
search_url: str
multi: bool
filter_mode: bool
free_text: bool
always_visible: bool
prefetch: int
sync_url: bool
register_element("search-select", "SearchSelect", SearchSelectProps)
_SearchSelect = custom_element_builder("search-select")
class FilterBarProps(TypedDict):
preset_list_url: str
preset_save_url: str
register_element("filter-bar", "FilterBar", FilterBarProps)
_FilterBarElement = custom_element_builder("filter-bar")
def SelectionFields(
*,
source: str,
name_prefix: str,
field_type: str = "text",
min_items: int = 1,
active: bool = False,
input_attributes: list[tuple[str, str]] | None = None,
) -> Node:
"""Render one synced form field per selected item of a source SearchSelect.
General-purpose: it mirrors the SearchSelect named ``source`` and emits an
input named ``f"{name_prefix}{item_id}"`` per selected item. Behavior lives
in ``ts/elements/selection-fields.ts``; this is just the server-rendered
light DOM (an empty rows container + a row ``<template>``). Inputs inherit
the global ``#add-form`` styling, so the markup stays minimal.
"""
row_template = Template(attributes=[("data-selection-fields-row", "")])[
Div(attributes=[("data-selection-fields-row-item", "")])[
Label(attributes=[("data-selection-fields-label", "")]),
Input(type=field_type, attributes=list(input_attributes or [])),
]
]
return _SelectionFields(
source=source,
name_prefix=name_prefix,
field_type=field_type,
min_items=min_items,
active="true" if active else "false",
)[
Div(attributes=[("data-selection-fields-rows", "")]),
row_template,
]