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>
This commit is contained in:
2026-06-20 14:22:59 +02:00
parent 4652f1ff55
commit 82416e149d
33 changed files with 2301 additions and 2168 deletions
+43
View File
@@ -146,6 +146,49 @@ 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,
+10 -20
View File
@@ -17,13 +17,11 @@ widget into a ``DateCriterion`` unchanged. All behaviour is wired by
``ts/date_range_picker.ts`` (compiled to ``dist/date_range_picker.js``).
"""
from common.components.core import Element, HTMLAttribute, Media, Node, Safe
from common.components.core import Element, Node, Safe
from common.components.custom_elements import _DateRangePicker
from common.components.primitives import Div, Input, Span
from common.time import DatePartSpec, date_parts
# Wired by ts/date_range_picker.ts (compiled to dist/).
_DATE_RANGE_MEDIA = Media(js=("dist/date_range_picker.js",))
_FIELD_CONTAINER_CLASS = (
"flex items-center gap-0.5 w-full rounded-base border border-default-medium "
"bg-neutral-secondary-medium text-sm text-heading p-1.5 cursor-text "
@@ -335,20 +333,12 @@ def DateRangePicker(
serializer needs no changes. ``min_value`` / ``max_value`` are ISO
``YYYY-MM-DD`` strings used to prefill both the segments and the hidden
inputs."""
attributes: list[HTMLAttribute] = [
("class", "date-range-picker relative"),
("data-date-range-picker", ""),
("data-input-name-prefix", input_name_prefix),
return _DateRangePicker(class_="relative")[
DateRangeField(
label=label,
input_name_prefix=input_name_prefix,
min_value=min_value,
max_value=max_value,
),
DateRangeCalendar(input_name_prefix=input_name_prefix),
]
return Div(
attributes=attributes,
children=[
DateRangeField(
label=label,
input_name_prefix=input_name_prefix,
min_value=min_value,
max_value=max_value,
),
DateRangeCalendar(input_name_prefix=input_name_prefix),
],
).with_media(_DATE_RANGE_MEDIA)
+203 -217
View File
@@ -4,7 +4,8 @@ from typing import NamedTuple
from django.db import models
from common.components.core import BaseComponent, Element, Media, Node, Safe
from common.components.core import BaseComponent, Element, Node, Safe
from common.components.custom_elements import _FilterBarElement, _RangeSlider
from common.components.date_range_picker import DateRangePicker
from common.components.primitives import Checkbox, Div, Input, Label, Radio, Span
from common.components.search_select import (
@@ -52,13 +53,6 @@ _FILTER_RADIO_CLASS = (
_FILTER_GRID_CLASS = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4"
# range_slider.js wires RangeSlider; ts/filter_bar.ts wires the bar chrome
# (Apply/Clear, presets, search injection). Widget media (dist/search_select.js,
# date_range_picker.js) bubbles up from the contained FilterSelect / picker.
_RANGE_SLIDER_MEDIA = Media(js=("dist/range_slider.js",))
_FILTER_BAR_MEDIA = Media(js=("dist/filter_bar.js",))
def _filter_parse(filter_json: str) -> dict:
if not filter_json:
return {}
@@ -340,157 +334,157 @@ def RangeSlider(
point_mode = bool(min_value and max_value and min_value == max_value)
initial_mode = "point" if point_mode else "range"
return Div(
attributes=[("class", "range-slider-block mb-4")],
children=[
# ── Label row ──
Div(
attributes=[("class", "flex items-center gap-2 mb-1")],
children=[
# The field label is rendered by the _filter_field wrapper.
# This composite widget has no single labelable root, so the
# label carries no `for` (the two inputs are named below).
Input(
attributes=[
("type", "number"),
("name", min_input_id),
("id", min_input_id),
("value", min_value),
("placeholder", min_placeholder),
(
"class",
f"{_RANGE_SLIDER_INPUT_CLASS}"
+ (" hidden" if point_mode else ""),
),
],
),
Span(
attributes=[
(
"class",
"range-dash text-body text-sm"
+ (" hidden" if point_mode else ""),
),
],
children=[""],
),
Input(
attributes=[
("type", "number"),
("name", max_input_id),
("id", max_input_id),
("value", max_value),
("placeholder", max_placeholder),
("class", _RANGE_SLIDER_INPUT_CLASS),
],
),
Element(
"button",
attributes=[
("type", "button"),
(
"class",
"range-mode-toggle p-1 text-body hover:text-heading "
"rounded cursor-pointer shrink-0",
),
(
"title",
"Toggle between range and single value",
),
(
"aria-label",
"Toggle between range and single value",
),
],
children=[
Span(
attributes=[
(
"class",
"range-mode-icon-range"
+ (" hidden" if point_mode else ""),
),
],
children=[Safe(_RANGE_ICON_SVG)],
),
Span(
attributes=[
(
"class",
"range-mode-icon-point"
+ ("" if point_mode else " hidden"),
),
],
children=[Safe(_POINT_ICON_SVG)],
),
],
),
],
),
# ── Slider row ──
Div(
attributes=[
("class", "range-slider relative h-10 w-5/6 select-none mt-1"),
("data-mode", initial_mode),
("data-min", str(range_min)),
("data-max", str(range_max)),
("data-step", str(step)),
],
children=[
Div(
attributes=[
(
"class",
"absolute top-1/2 -translate-y-1/2 w-full h-2 "
"rounded-full bg-neutral-quaternary",
),
],
),
Div(
attributes=[
(
"class",
"range-track-fill absolute top-1/2 -translate-y-1/2 "
"h-2 bg-brand rounded-full",
),
("style", "left:0;width:100%"),
],
),
# Min handle (hidden in point mode via JS)
Div(
attributes=[
(
"class",
"range-handle range-handle-min absolute top-1/2 "
"-translate-y-1/2 w-5 h-5 bg-brand rounded-full "
"border-2 border-white shadow cursor-pointer "
"hover:scale-110 transition-transform",
),
("data-target", min_input_id),
(
"style",
"left:0" + (";display:none" if point_mode else ""),
),
],
),
# Max handle
Div(
attributes=[
(
"class",
"range-handle range-handle-max absolute top-1/2 "
"-translate-y-1/2 w-5 h-5 bg-brand rounded-full "
"border-2 border-white shadow cursor-pointer "
"hover:scale-110 transition-transform",
),
("data-target", max_input_id),
("style", "left:100%"),
],
),
],
),
],
).with_media(_RANGE_SLIDER_MEDIA)
return _RangeSlider(
min=range_min,
max=range_max,
step=int(step),
mode=initial_mode,
class_="mb-4 block",
)[
# ── Label row ──
Div(
attributes=[("class", "flex items-center gap-2 mb-1")],
children=[
# The field label is rendered by the _filter_field wrapper.
# This composite widget has no single labelable root, so the
# label carries no `for` (the two inputs are named below).
Input(
attributes=[
("type", "number"),
("name", min_input_id),
("id", min_input_id),
("value", min_value),
("placeholder", min_placeholder),
(
"class",
f"{_RANGE_SLIDER_INPUT_CLASS}"
+ (" hidden" if point_mode else ""),
),
],
),
Span(
attributes=[
(
"class",
"range-dash text-body text-sm"
+ (" hidden" if point_mode else ""),
),
],
children=[""],
),
Input(
attributes=[
("type", "number"),
("name", max_input_id),
("id", max_input_id),
("value", max_value),
("placeholder", max_placeholder),
("class", _RANGE_SLIDER_INPUT_CLASS),
],
),
Element(
"button",
attributes=[
("type", "button"),
(
"class",
"range-mode-toggle p-1 text-body hover:text-heading "
"rounded cursor-pointer shrink-0",
),
(
"title",
"Toggle between range and single value",
),
(
"aria-label",
"Toggle between range and single value",
),
],
children=[
Span(
attributes=[
(
"class",
"range-mode-icon-range"
+ (" hidden" if point_mode else ""),
),
],
children=[Safe(_RANGE_ICON_SVG)],
),
Span(
attributes=[
(
"class",
"range-mode-icon-point"
+ ("" if point_mode else " hidden"),
),
],
children=[Safe(_POINT_ICON_SVG)],
),
],
),
],
),
# ── Track row ──
Div(
attributes=[
("class", "relative h-10 w-5/6 select-none mt-1"),
("data-range-track", ""),
],
children=[
Div(
attributes=[
(
"class",
"absolute top-1/2 -translate-y-1/2 w-full h-2 "
"rounded-full bg-neutral-quaternary",
),
],
),
Div(
attributes=[
(
"class",
"range-track-fill absolute top-1/2 -translate-y-1/2 "
"h-2 bg-brand rounded-full",
),
("style", "left:0;width:100%"),
],
),
# Min handle (hidden in point mode via JS)
Div(
attributes=[
(
"class",
"range-handle range-handle-min absolute top-1/2 "
"-translate-y-1/2 w-5 h-5 bg-brand rounded-full "
"border-2 border-white shadow cursor-pointer "
"hover:scale-110 transition-transform",
),
("data-target", min_input_id),
(
"style",
"left:0" + (";display:none" if point_mode else ""),
),
],
),
# Max handle
Div(
attributes=[
(
"class",
"range-handle range-handle-max absolute top-1/2 "
"-translate-y-1/2 w-5 h-5 bg-brand rounded-full "
"border-2 border-white shadow cursor-pointer "
"hover:scale-110 transition-transform",
),
("data-target", max_input_id),
("style", "left:100%"),
],
),
],
),
]
_DATE_RANGE_INPUT_CLASS = (
@@ -588,7 +582,7 @@ def _filter_collapse_button() -> Node:
)
def _filter_action_row(preset_list_url: str, preset_save_url: str) -> Node:
def _filter_action_row() -> Node:
return Div(
attributes=[("class", "flex gap-3 items-center")],
children=[
@@ -609,10 +603,7 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> Node:
"button",
attributes=[
("type", "button"),
(
"onclick",
f"clearFilterBar('{_FILTER_FORM_ID}', '{_FILTER_INPUT_ID}')",
),
("data-filter-bar-clear", ""),
(
"class",
"px-4 py-2 text-sm font-medium text-gray-900 bg-white "
@@ -633,6 +624,7 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> Node:
attributes=[
("type", "text"),
("id", "preset-name-input"),
("data-filter-bar-preset-name", ""),
("placeholder", "Preset name..."),
(
"class",
@@ -647,7 +639,7 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> Node:
attributes=[
("type", "button"),
("id", "save-preset-btn"),
("onclick", "showPresetNameInput()"),
("data-filter-bar-save", ""),
(
"class",
"px-4 py-2 text-sm font-medium text-gray-900 "
@@ -664,10 +656,7 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> Node:
attributes=[
("type", "button"),
("id", "confirm-save-preset-btn"),
(
"onclick",
f"savePreset('{_FILTER_FORM_ID}', '{_FILTER_INPUT_ID}', '{preset_save_url}')",
),
("data-filter-bar-confirm-save", ""),
(
"class",
"hidden px-4 py-2 text-sm font-medium text-white "
@@ -683,7 +672,6 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> Node:
attributes=[
("id", "preset-dropdown"),
("class", "relative"),
("data-preset-list-url", preset_list_url),
],
children=[
Span(
@@ -702,14 +690,11 @@ class _FilterBarBase(BaseComponent):
Subclasses implement ``build_fields()`` returning the per-entity body
(grids, sliders, checkboxes); this base wraps it in the collapse toggle,
the form, the hidden filter-json input and the Apply/Clear/preset action
row. ``filter_bar.js`` (declared as this component's ``media``) wires the
chrome; widget media (search_select.js, range_slider.js,
date_range_picker.js) bubbles up from the contained widgets via the node
row. ``filter-bar.js`` (declared via ``_FilterBarElement``) wires the
chrome; widget media bubbles up from the contained widgets via the node
tree, so the view never threads ``scripts=`` by hand.
"""
media = _FILTER_BAR_MEDIA
def __init__(
self,
filter_json: str = "",
@@ -726,47 +711,49 @@ class _FilterBarBase(BaseComponent):
raise NotImplementedError
def render(self) -> Node:
return Div(
attributes=[("id", "filter-bar"), ("class", "mb-6")],
children=[
_filter_collapse_button(),
Div(
attributes=[
("id", "filter-bar-body"),
(
"class",
"hidden border border-default-medium rounded-base p-4 "
"bg-neutral-secondary-medium/50",
),
],
children=[
Element(
"form",
attributes=[
("id", _FILTER_FORM_ID),
("onsubmit", "return applyFilterBar(event)"),
],
children=[
Input(
attributes=[
("type", "hidden"),
("id", _FILTER_INPUT_ID),
("name", "filter"),
# NB: attribute values are escaped, so the
# raw JSON passes through (no double-escape).
("value", self.filter_json),
],
),
*self.build_fields(),
_filter_action_row(
self.preset_list_url, self.preset_save_url
),
],
),
],
),
],
)
return _FilterBarElement(
preset_list_url=self.preset_list_url,
preset_save_url=self.preset_save_url,
)[
Div(
attributes=[("id", "filter-bar"), ("class", "mb-6")],
children=[
_filter_collapse_button(),
Div(
attributes=[
("id", "filter-bar-body"),
(
"class",
"hidden border border-default-medium rounded-base p-4 "
"bg-neutral-secondary-medium/50",
),
],
children=[
Element(
"form",
attributes=[
("id", _FILTER_FORM_ID),
],
children=[
Input(
attributes=[
("type", "hidden"),
("id", _FILTER_INPUT_ID),
("name", "filter"),
# NB: attribute values are escaped, so the
# raw JSON passes through (no double-escape).
("value", self.filter_json),
],
),
*self.build_fields(),
_filter_action_row(),
],
),
],
),
],
)
]
class FilterBar(_FilterBarBase):
@@ -1557,7 +1544,6 @@ def StringFilter(
value=mod_val,
attributes=[
("data-string-modifier-radio", ""),
("onclick", "toggleStringFilterInput(this)"),
],
)
for mod_val, lbl in options
+38 -60
View File
@@ -33,7 +33,8 @@ from collections.abc import Callable, Iterable
from typing import TypedDict
from common.components.core import Attributes, Element, HTMLAttribute, Media, Node
from common.components.core import Attributes, Element, HTMLAttribute, Node
from common.components.custom_elements import _SearchSelect
from common.components.primitives import (
DISABLED_WITHIN_CLASS,
Div,
@@ -43,9 +44,6 @@ from common.components.primitives import (
Template,
)
# Both comboboxes are wired by ts/search_select.ts (compiled to dist/).
_SEARCH_SELECT_MEDIA = Media(js=("dist/search_select.js",))
class SearchSelectOption(TypedDict):
value: str | int
@@ -210,27 +208,20 @@ def _option_row(option: SearchSelectOption) -> Node:
)
def _combobox_shell(
def _combobox_children(
*,
container_attributes: Attributes,
pills: Node,
search_attributes: Attributes,
options_children: list[Node],
always_visible: bool,
items_visible: int,
templates: list[Node] | None = None,
) -> Node:
"""Assemble the shared, domain-agnostic combobox skeleton.
) -> list[Node]:
"""Build and return the shared combobox interior nodes.
Every combobox built on top of this shell has the same three regions in the
same order: the ``pills`` region, the search box, and the options panel (which
always carries a trailing no-results node). Callers supply the already-built
``pills`` region, the ``search_attributes`` for the text box, the
``options_children`` (value rows plus any pinned pseudo-options), the
``container_attributes`` that carry the widget's identity and behaviour flags,
and any ``templates`` (inert ``<template>`` prototypes the JS clones for
dynamically-added rows/pills). The shell knows nothing about how individual
rows or pills look.
Returns the three content regions (pills, search box, options panel) plus
any templates — ready to be placed as children of the caller's container
element. The shell knows nothing about how individual rows or pills look.
"""
search = Input(attributes=search_attributes)
@@ -251,8 +242,7 @@ def _combobox_shell(
children=[*options_children, no_results],
)
children: list[Node] = [pills, search, options_panel, *(templates or [])]
return Div(attributes=container_attributes, children=children)
return [pills, search, options_panel, *(templates or [])]
def SearchSelect(
@@ -337,30 +327,26 @@ def SearchSelect(
)
)
container_attributes: list[HTMLAttribute] = [
("data-search-select", ""),
("data-name", name),
("data-search-url", search_url),
("data-multi", "true" if multi_select else "false"),
("data-always-visible", "true" if always_visible else "false"),
("data-items-visible", str(items_visible)),
("data-items-scroll", str(items_scroll)),
("data-prefetch", str(prefetch)),
("data-sync-url", "true" if sync_url else "false"),
("class", _CONTAINER_CLASS),
]
if id:
container_attributes.append(("id", id))
return _combobox_shell(
container_attributes=container_attributes,
children = _combobox_children(
pills=pills,
search_attributes=search_attrs,
options_children=option_rows,
always_visible=always_visible,
items_visible=items_visible,
templates=templates,
).with_media(_SEARCH_SELECT_MEDIA)
)
return _SearchSelect(
name=name,
search_url=search_url,
multi="true" if multi_select else "false",
filter_mode="false",
free_text="false",
always_visible="true" if always_visible else "false",
prefetch=prefetch,
sync_url="true" if sync_url else "false",
class_=_CONTAINER_CLASS,
id_=id or None,
)[*children]
def _filter_remove_button() -> Node:
@@ -567,35 +553,27 @@ def FilterSelect(
)
)
container_attributes: list[HTMLAttribute] = [
("data-search-select", ""),
("data-search-select-mode", "filter"),
("data-name", field_name),
("data-search-url", search_url),
("data-multi", "true"),
("data-always-visible", "false"),
("data-items-visible", str(items_visible)),
("data-items-scroll", str(items_scroll)),
("data-prefetch", str(prefetch)),
("data-sync-url", "false"),
("class", _CONTAINER_CLASS),
]
if free_text:
container_attributes.append(("data-search-select-free-text", "true"))
if modifier:
container_attributes.append(("data-modifier", modifier))
if id:
container_attributes.append(("id", id))
return _combobox_shell(
container_attributes=container_attributes,
children = _combobox_children(
pills=pills,
search_attributes=search_attributes,
options_children=[*modifier_rows, *value_rows],
always_visible=False,
items_visible=items_visible,
templates=templates,
).with_media(_SEARCH_SELECT_MEDIA)
)
return _SearchSelect(
name=field_name,
search_url=search_url,
multi="true",
filter_mode="true",
free_text="true" if free_text else "false",
always_visible="false",
prefetch=prefetch,
sync_url="false",
class_=_CONTAINER_CLASS,
id_=id or None,
data_modifier=modifier or None,
)[*children]
def searchselect_selected(