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
+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