The flip-up branch cleared inline `top` to "", which let the menu's
`top-[105%]` utility class reassert top:105% on the now-fixed element —
collapsing the menu to a 2px sliver below the viewport, so toggles near the
viewport bottom appeared not to open. Set the unused anchor to "auto" so the
inline value wins over the class. Add an e2e regression for the flip-up path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The device/status dropdown menu is absolutely positioned inside the session
list's overflow-x-auto wrapper. Because overflow-x:auto forces overflow-y:auto,
a menu taller than a short table was clipped (issue #39). Open the menu with
position:fixed anchored to its toggle so it escapes the clipping ancestor,
bound it to the viewport with an internal scroll, flip it up when there is more
room above, and reposition on scroll/resize while open.
Fixes#39.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The id (e.g. id_related_game) sat on the <search-select> wrapper, a
non-labelable custom element. Consequences:
- <label for="id_X"> focused nothing (a11y gap)
- .disabled / .focus() on #id_X silently no-oped
- add_purchase.ts needed a [data-search-select-search] descendant
workaround to gate related_game on the type field
id is now on the [data-search-select-search] <input>, making it a real
labelable, disableable control. add_purchase.ts drops the workaround
and gates via #id_related_game directly. E2e tests updated; new test
asserts label-click focuses the search box.
Closes#30
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>
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>
- Add ts/htmx-redirect-toast.ts: typed port of the hx-redirect-toast htmx
extension. Stays a classic (non-module) script — only touches the global
htmx and registers an extension; layout.py now serves dist/htmx-redirect-toast.js
- Delete games/static/js/utils.js: the legacy hand-written copy is dead — every
compiled module imports dist/utils.js (from ts/utils.ts); nothing references
the old path
With this, the only first-party JS served is compiled from ts/; the sole
remaining hand-written .js in static is the vendored datepicker.umd.js bundle.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add ts/year_picker.ts: typed onSwap port of the year-picker glue. Datepicker
declared as an ambient global (vendored UMD); PickerElement types the
_pickerInstance prop the Alpine toggle button reaches
- Remove the duplicate inline <script> from the YearPicker component (was a JS
blob in a Python f-string — the CLAUDE.md anti-pattern) and the orphaned
games/static/js/year_picker.js that nothing loaded; the component now declares
dist/year_picker.js as media alongside the datepicker UMD bundle
- Module defer semantics keep the classic UMD bundle running before the
deferred year_picker module, so Datepicker is defined in time
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add ts/toast.ts: typed port of the Alpine toast store + window.toast +
window.fetchWithHtmxTriggers. Toast / ToastStore / ToastMessage interfaces
type the store and the show-toast CustomEvent detail; Alpine declared as a
type-only ambient global
- Declare window.toast in ts/globals.d.ts
- Stays a classic (non-module) script — no import/export — so it keeps defining
globals; layout.py now serves dist/toast.js
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add ts/date_range_picker.ts: typed port. CalendarState interface (with the
dynamically-assigned refreshFromField) and an Anchor union replace the loose
state object; date helpers and DOM queries fully typed; var → const/let
- Replace the DOMContentLoaded + per-element guard-flag + window global with
onSwap("[data-date-range-picker]", ...), the documented init pattern — so the
picker now also initializes inside htmx-swapped fragments. Drops the dead
window.initDateRangePickers export
- Point the DateRangePicker component Media at dist/date_range_picker.js and load
it as an ES module in the e2e page (was a deferred classic script)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add ts/range_slider.ts: typed port of the custom range-slider widget. Number
inputs typed as HTMLInputElement; setTargetValue coerces via String(); mouse
handlers typed MouseEvent; var → const/let
- Point the RangeSlider component Media and every e2e/test reference at the
compiled dist/range_slider.js
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add ts/filter_bar.ts: typed port of the filter bar. Criterion / PillEntry /
RangeField / DeselectableRadio interfaces replace the loose objects and the
radio.wasChecked custom property; var → const/let throughout
- Window entry points (applyFilterBar/clearFilterBar/toggleStringFilterInput/
showPresetNameInput/savePreset) declared in ts/globals.d.ts; readSearchSelect
now called as window.readSearchSelect
- Drop the dead selectValue helper; factor the repeated path→mode mapping into
presetMode()
- Point the FilterBar component Media and every e2e/test reference at the
compiled dist/filter_bar.js
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add ts/search_select.ts: typed port of the SearchSelect/FilterSelect widget.
Exports SearchSelectOption / SearchSelectChangeDetail as the single source of
truth for the "search-select:change" event contract
- add_purchase.ts now imports those types via `import type` (no runtime
coupling), instead of redefining them locally
- Declare window.readSearchSelect in ts/globals.d.ts
- Point the SearchSelect component Media and every view/e2e/test reference at
the compiled dist/search_select.js
- Update doc comments in common/components/search_select.py to name the TS source
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add ts/add_purchase.ts: typed port of add_purchase.js. Replaces getEl with
document.querySelector; types the search-select:change CustomEvent detail
(SearchSelectChangeDetail / SearchSelectOption)
- Point add_purchase / edit_purchase views at compiled dist/add_purchase.js
- Delete add_edition.js: no Edition model/view/url/template references it
(feature was removed; the script was dead)
- Delete the now-superseded add_game.js / add_purchase.js source files
- Tighten test_rendered_pages assertions to the dist/ script paths
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>
The Game status dropdown is now a <game-status-selector> light-DOM custom
element: the Python builder emits the tag + kebab attrs htpy-style, behavior
lives in ts/elements/{dropdown,game-status-selector}.ts wired by the native
connectedCallback, and GameStatusSelectorProps is the codegen'd contract. The
~70-line inline-Alpine f-string is gone.
Also fix SimpleTable to collect and re-attach the media of its row/header
nodes: it stringifies cells into the table markup, which silently dropped each
cell component's declared Media — so a <game-status-selector> in a cell never
got its <script> emitted. Now Page() emits it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>