Finish the behavioural refactor from #28: no first-party JS lives on the
global object solely to be reachable from a server-rendered inline on*
attribute, and no inline Alpine blobs remain in the filter bar / year picker.
- Filter-bar collapse: drop the inline onclick for a delegated click listener
on the persistent <filter-bar> custom element (data-filter-bar-toggle). The
inner #filter-bar body is htmx-swapped while connectedCallback does not re-run,
so delegation on the host preserves the swap-survival the inline handler had.
- YearPicker: convert the Alpine x-data/x-on/x-ref/_pickerInstance f-string into
a <year-picker> custom element with typed props (YearPickerProps). Behavior
moves to ts/elements/year-picker.ts; ts/year_picker.ts and _YEAR_PICKER_MEDIA
are removed. The builder lives in primitives.py (next to YearPicker) to avoid a
circular import; registration stays in custom_elements.py for codegen.
- Add bindPopupDismiss (ts/utils.ts): shared Escape + outside-click dismiss with
a cleanup return and an extraInside hook for popups mounted on document.body.
Adopted by date-range-picker.ts (1:1) and year-picker.ts (Datepicker popup is
body-mounted, passed as an extra inside root).
Follow-up #49 tracks unifying popup/dismiss/positioning across the remaining
dropdown/search-select/Flowbite cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
A multi-game Purchase is now treated as an *unsplittable* bundle (one
price, whole-purchase refund). Independently-refundable multi-item orders
(e.g. a Steam cart) are instead recorded as N separate single-game
purchases, so per-game pricing and per-game refunds work with the
existing single-purchase machinery — no through-model needed.
Add-purchase form (single form, single endpoint):
- 1 game: unchanged.
- 2+ games: a "Separate price per game" toggle appears (default off =
one bundle price). On, the bundle Price hides and one price input per
game appears; the view creates one single-game Purchase each from
price_for_game_<id>. `price` is now optional so combined mode still
validates.
Split action:
- A Split button on multi-game purchase rows opens a confirmation modal
that replaces the bundle with one single-game purchase per game (price
split evenly, needs_price_update set), then HX-Redirects to the list.
New general-purpose `selection-fields` custom element renders one synced
form field per selected item of a source SearchSelect (consuming the
existing search-select:change contract); it knows nothing about prices,
so it is reusable. Behavior in ts/elements/selection-fields.ts.
Adds the bundle-vs-separate-purchases convention to CLAUDE.md, a split
icon, and unit + Playwright e2e coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>