From b3fa7fac9694eee2db0c6d7f20aba7690531147c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sat, 20 Jun 2026 15:54:00 +0200 Subject: [PATCH] =?UTF-8?q?Remove=20inline-handler=20=E2=86=92=20window.*?= =?UTF-8?q?=20contract=20(issue=20#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 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 --- common/components/custom_elements.py | 12 +++++ common/components/filters.py | 6 +-- common/components/primitives.py | 81 +++++++++++++++++----------- ts/elements/date-range-picker.ts | 22 +++----- ts/elements/filter-bar.ts | 10 ++++ ts/elements/year-picker.ts | 81 ++++++++++++++++++++++++++++ ts/utils.ts | 38 +++++++++++++ ts/year_picker.ts | 54 ------------------- 8 files changed, 201 insertions(+), 103 deletions(-) create mode 100644 ts/elements/year-picker.ts delete mode 100644 ts/year_picker.ts diff --git a/common/components/custom_elements.py b/common/components/custom_elements.py index a88e3d4..ff87988 100644 --- a/common/components/custom_elements.py +++ b/common/components/custom_elements.py @@ -189,6 +189,18 @@ register_element("filter-bar", "FilterBar", FilterBarProps) _FilterBarElement = custom_element_builder("filter-bar") +class YearPickerProps(TypedDict): + selected_year: str # "" for the all-time/empty state + available_years: str # csv, e.g. "2019,2020" + url_template: str # contains the literal __year__ placeholder + + +# The builder lives in primitives.py (next to YearPicker, which +# uses it) because custom_elements imports from primitives — registering here +# would be a circular import. Registration is codegen-only, so it belongs here. +register_element("year-picker", "YearPicker", YearPickerProps) + + def SelectionFields( *, source: str, diff --git a/common/components/filters.py b/common/components/filters.py index 9fd64ac..7134125 100644 --- a/common/components/filters.py +++ b/common/components/filters.py @@ -563,10 +563,8 @@ def _filter_collapse_button() -> Node: ("type", "button"), # Slider handles are positioned in percentages, so initializing # them while the body is hidden is safe — no re-init on reveal. - ( - "onclick", - "document.getElementById('filter-bar-body').classList.toggle('hidden')", - ), + # Click is wired by filter-bar.ts (no inline handler). + ("data-filter-bar-toggle", ""), ( "class", "flex items-center gap-2 text-sm font-medium text-body " diff --git a/common/components/primitives.py b/common/components/primitives.py index 3c4ed0b..182194e 100644 --- a/common/components/primitives.py +++ b/common/components/primitives.py @@ -550,14 +550,19 @@ def StaticScript(filename: str) -> SafeText: return mark_safe(f'') -# Media for the Flowbite-datepicker year picker: the vendored UMD bundle -# (classic script) plus the ts/year_picker.ts glue (compiled module). Declared -# on the YearPicker node so Page() loads both wherever a YearPicker appears. The -# UMD bundle is a classic script (runs during parse) while year_picker.js is a -# deferred module (runs after parse), so Datepicker is defined by the time the -# module executes regardless of tag order. -_YEAR_PICKER_MEDIA = Media( - js_external=("datepicker.umd.js",), js=("dist/year_picker.js",) +# The custom element wraps the Flowbite-datepicker year grid. +# The builder auto-attaches dist/elements/year-picker.js; the vendored UMD +# bundle (classic script, runs during parse) is merged in via with_media so +# Datepicker is defined by the time the deferred element module executes. +_YearPicker = custom_element_builder("year-picker") +_DATEPICKER_MEDIA = Media(js_external=("datepicker.umd.js",)) + +# The down-chevron rendered inside the YearPicker button. Trusted static SVG. +_YEAR_PICKER_CHEVRON = Safe( + '' ) @@ -574,8 +579,10 @@ def YearPicker( placeholder, substituted with the chosen year in JS (keeps this component decoupled from the project's URL names). - The Flowbite-datepicker UMD bundle is declared as ``media`` on the returned - node, so ``Page()`` loads it automatically. + Behavior lives in ``ts/elements/year-picker.ts``; this renders the light + DOM (toggle button + hidden datepicker input). The element module and the + Flowbite UMD bundle are declared as ``media`` on the node, so ``Page()`` + loads both automatically. """ label = str(year) if year is not None else "Choose a year" selected = str(year) if year is not None else "" @@ -586,26 +593,40 @@ def YearPicker( "hover:bg-neutral-tertiary-medium focus:ring-4 focus:ring-brand-medium" ) years_csv = ",".join(str(y) for y in available_years) - return Safe( - f"""
- - -
""", - media=_YEAR_PICKER_MEDIA, - ) + return _YearPicker( + attributes=[ + ("selected-year", selected), + ("available-years", years_csv), + ("url-template", url_template), + ("class", "relative inline-block"), + ], + children=[ + Element( + "button", + attributes=[ + ("type", "button"), + ("data-year-picker-toggle", ""), + ( + "class", + "inline-flex items-center rounded-base px-4 py-2 " + f"text-sm font-medium {classes}", + ), + ], + children=[label, _YEAR_PICKER_CHEVRON], + ), + Input( + attributes=[ + ("id", "year-picker-input"), + ("class", "absolute opacity-0 pointer-events-none"), + ( + "style", + "width: 1px; height: 1px; padding: 0; margin: -1px; " + "overflow: hidden; clip: rect(0,0,0,0); border: 0;", + ), + ], + ), + ], + ).with_media(_DATEPICKER_MEDIA) # Form-field rendering. The element classes (label/error/checkbox-row + the diff --git a/ts/elements/date-range-picker.ts b/ts/elements/date-range-picker.ts index 2f0fc44..6d64f74 100644 --- a/ts/elements/date-range-picker.ts +++ b/ts/elements/date-range-picker.ts @@ -20,6 +20,7 @@ * NB: class strings below are emitted verbatim so the Tailwind scanner picks * them up — keep them as plain literals. */ +import { bindPopupDismiss } from "../utils.js"; type Anchor = "" | "start" | "end"; @@ -517,22 +518,13 @@ function createCalendarState( closePopup(); }); - const onKeyDown = (event: KeyboardEvent): void => { - if (event.key === "Escape" && state.open) closePopup(); - }; - const onMouseDown = (event: MouseEvent): void => { - if (state.open && !picker.contains(event.target as Node)) closePopup(); - }; - document.addEventListener("keydown", onKeyDown); - document.addEventListener("mousedown", onMouseDown); + const cleanup = bindPopupDismiss({ + host: picker, + isOpen: () => state.open, + close: closePopup, + }); - return { - state, - cleanup() { - document.removeEventListener("keydown", onKeyDown); - document.removeEventListener("mousedown", onMouseDown); - }, - }; + return { state, cleanup }; } function initPicker(picker: HTMLElement): () => void { diff --git a/ts/elements/filter-bar.ts b/ts/elements/filter-bar.ts index cfc5a58..6e9fb55 100644 --- a/ts/elements/filter-bar.ts +++ b/ts/elements/filter-bar.ts @@ -403,6 +403,16 @@ class FilterBarElement extends HTMLElement { const form = this.querySelector("form"); if (!form) return; + // Delegated on the persistent custom element so the toggle keeps working + // after the inner #filter-bar body is htmx-swapped — connectedCallback does + // not re-run for inner swaps, so a direct listener on the button would be + // lost (this is why the toggle was previously an inline onclick). + this.addEventListener("click", (event) => { + if ((event.target as Element).closest("[data-filter-bar-toggle]")) { + this.querySelector("#filter-bar-body")?.classList.toggle("hidden"); + } + }); + form.addEventListener("submit", (event) => { event.preventDefault(); const filter = buildFilterJSON(form); diff --git a/ts/elements/year-picker.ts b/ts/elements/year-picker.ts new file mode 100644 index 0000000..7046896 --- /dev/null +++ b/ts/elements/year-picker.ts @@ -0,0 +1,81 @@ +/** + * YearPicker — custom element wrapping the Flowbite-datepicker year grid behind + * the YearPicker component (common/components/primitives.py). + * + * The component renders a toggle