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>
Follow-ups on the add-form fixes:
- syncSelectInputUntilChanged now actually stops mirroring once the user edits
the target (the "UntilChanged" contract). The old focus-based stop was a
no-op (wrong removeEventListener reference), so live sync kept clobbering a
manually-edited Sort name. Track dirty targets in a Set keyed by syncData
index; programmatic writes don't fire "input", so only real user edits mark a
target dirty. Drops the dead focus listener.
- SearchSelect now greys itself when disabled, via has-[:disabled]: utilities on
its container class — the visible "box" is the wrapper <div>, so disabling the
transparent inner input alone left it looking active. The component owns its
disabled appearance; callers only toggle the inner control's `disabled`.
- Document the composite-widget disabling approach in CLAUDE.md and the
SearchSelect docstring.
Extends the e2e tests: sync drops after a manual Sort name edit; disabled
related-game wrapper computes opacity 0.5 (and 1 when re-enabled).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two bugs in the add forms, both root-caused via the e2e harness:
1. add_game Name → Sort name never synced. syncSelectInputUntilChanged was
scoped to "form", but the first <form> on every page is the navbar logout
form — the add-form fields live in a later form, so the delegated listener
never heard their events. Scope to "#add-form" (the add-form wrapper). Also
switch the sync from the "change" event to "input" so Sort name mirrors Name
live as you type, not only on blur.
2. add_purchase Related game not disabled when Type == Game.
disableElementsWhenTrue set `.disabled` on #id_related_game, which is the
SearchSelect wrapper <div> (a <div> ignores `disabled`). Target the inner
[data-search-select-search] input instead, so the widget is actually disabled.
Adds two e2e regression tests (live sync; type-game disables the related-game
search input and re-enables it for other types).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>