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>
6.5 KiB
Convert Remaining onSwap Widgets to Custom Elements
Date: 2026-06-20
Issue: #18
Relates to: #17 (TS migration), spec 2026-06-13-html-js-authoring-design.md
Context
PR #16 established the custom-element pattern (TypeScript custom elements, connectedCallback lifecycle, codegen'd typed prop contracts) and converted three components. Four interactive widgets still use the old pattern: a hand-written .ts file registered with onSwap(selector, fn) + data-* attributes.
Goal: Migrate all four remaining widgets to the custom-element pattern so the whole interactive surface uses one model.
Widgets and Dependency Order
Convert in this order (least-to-most dependent):
range-slider— no cross-widget depsdate-range-picker— no cross-widget depssearch-select— no deps; exportsreadSearchSelect()consumed by filter-barfilter-bar— importsreadSearchSelect; removes allwindow.*globals
onSwap is NOT retired by this issue — year_picker.ts and add_purchase.ts still use it (see #17).
Per-Widget Conversion Pattern
Each widget follows the same steps:
Python side
- Add
XxxProps(TypedDict)tocommon/components/custom_elements.py - Call
register_element("xxx", "Xxx", XxxProps)immediately after - Create
_Xxx = custom_element_builder("xxx") - Update the Python component (in
filters.py,search_select.py, ordate_range_picker.py) to use the builder; remove old_XXX_MEDIAand.with_media(...)calls
TypeScript side
- Create
ts/elements/xxx.ts(move logic fromts/xxx.ts) - Replace IIFE +
onSwap(selector, fn)withclass XxxElement extends HTMLElement { connectedCallback() { ... } } - Read typed props via generated
readXxxProps(this)instead ofel.getAttribute("data-xxx") - Add
disconnectedCallback()to remove any document-level event listeners - End with
customElements.define("xxx", XxxElement)
Build
uv run manage.py gen_element_types— regeneratests/generated/props.tsmake ts— compiles all TypeScriptmake check— linting + type-check + tests
E2E
- Update Playwright locators to match new element tags and attribute names
Widget Specifics
range-slider
Props:
class RangeSliderProps(TypedDict):
min: int
max: int
step: int
mode: str # "range" | "point"
Structural change: <range-slider> replaces the outer .range-slider-block wrapper div AND the inner .range-slider div. The mode toggle button and the track/handles all become light-DOM children of <range-slider>. This eliminates slider.closest(".range-slider-block") — the TS can use this.querySelector(".range-mode-toggle") directly.
The data-mode attribute becomes the typed mode prop (attribute mode on the element). The JS updates this attribute on toggle: this.setAttribute("mode", newMode).
E2E: .range-slider-block → range-slider; slider[data-mode] → range-slider[mode].
date-range-picker
Props:
class DateRangePickerProps(TypedDict):
input_name_prefix: str
Structural change: <date-range-picker> replaces the outer <div data-date-range-picker data-input-name-prefix="...">. DateRangeField and DateRangeCalendar remain unchanged as light-DOM children.
The data-input-name-prefix attribute on DateRangeCalendar can be removed since the prefix is now a typed prop on the element itself, readable as readDateRangePickerProps(this).inputNamePrefix.
search-select
Props:
class SearchSelectProps(TypedDict):
name: str
search_url: str # empty string when no URL
multi: bool
filter_mode: bool # true for FilterSelect; replaces data-search-select-mode="filter"
free_text: bool
always_visible: bool
prefetch: int
sync_url: bool
Structural change: <search-select> replaces the outer <div data-search-select ...>. All internal child elements ([data-search-select-search], [data-search-select-options], etc.) remain unchanged.
readSearchSelect export: Remove window.readSearchSelect = .... Export as a named module function:
export function readSearchSelect(scope: HTMLElement): void { ... }
filter_bar.ts will import it. Update the function to query search-select[filter-mode="true"] instead of [data-search-select][data-search-select-mode="filter"].
E2E: [data-search-select][data-name="status"] → search-select[name="status"].
filter-bar
Props:
class FilterBarProps(TypedDict):
preset_list_url: str
preset_save_url: str
Structural change: <filter-bar> wraps the entire filter bar structure (collapse toggle + form + action row). The Python _FilterBarBase.render() wraps its output in the builder.
Window globals removed: applyFilterBar, clearFilterBar, toggleStringFilterInput, showPresetNameInput, savePreset are no longer assigned to window. connectedCallback wires all handlers:
this.querySelector("form")→submitlistener (replacesonsubmit)this.querySelector("[data-filter-bar-clear]")→clicklistenerthis.querySelector("[data-filter-bar-save]")→clicklistenerthis.querySelector("[data-filter-bar-confirm-save]")→clicklistenerthis.querySelectorAll("[data-string-modifier-radio]")→changelisteners
Python changes in filters.py:
- Remove
onsubmit="return applyFilterBar(event)"from form - Replace
onclick="clearFilterBar(...)"→data-filter-bar-clear - Replace
onclick="showPresetNameInput()"→data-filter-bar-save - Replace
onclick="savePreset(...)"→data-filter-bar-confirm-save - Replace
onclick="toggleStringFilterInput(this)"→data-string-modifier-radio(already present) - Move
preset_list_urlfromdata-preset-list-urlon#preset-dropdownto a typed prop on<filter-bar> - Preset dropdown:
this.querySelector("[data-preset-dropdown]")(add this attr)
Import: filter-bar.ts imports { readSearchSelect } from ./search-select.js.
globals.d.ts: Remove all entries except fetchWithHtmxTriggers and toast (which remain as globals).
Verification
uv run manage.py gen_element_types # codegen passes
make ts # tsc --noEmit passes
make test # unit tests pass
make test-e2e # e2e tests pass (after locator updates)
make check # full CI gate
Manual visual check each widget after conversion (per issue requirement).