Remove inline-handler → window.* contract (issue #28)

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>
This commit is contained in:
2026-06-20 15:54:00 +02:00
parent 82416e149d
commit b3fa7fac96
8 changed files with 201 additions and 103 deletions
+12
View File
@@ -189,6 +189,18 @@ register_element("filter-bar", "FilterBar", FilterBarProps)
_FilterBarElement = custom_element_builder("filter-bar") _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 <year-picker> 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( def SelectionFields(
*, *,
source: str, source: str,
+2 -4
View File
@@ -563,10 +563,8 @@ def _filter_collapse_button() -> Node:
("type", "button"), ("type", "button"),
# Slider handles are positioned in percentages, so initializing # Slider handles are positioned in percentages, so initializing
# them while the body is hidden is safe — no re-init on reveal. # them while the body is hidden is safe — no re-init on reveal.
( # Click is wired by filter-bar.ts (no inline handler).
"onclick", ("data-filter-bar-toggle", ""),
"document.getElementById('filter-bar-body').classList.toggle('hidden')",
),
( (
"class", "class",
"flex items-center gap-2 text-sm font-medium text-body " "flex items-center gap-2 text-sm font-medium text-body "
+51 -30
View File
@@ -550,14 +550,19 @@ def StaticScript(filename: str) -> SafeText:
return mark_safe(f'<script src="{static("js/" + filename)}"></script>') return mark_safe(f'<script src="{static("js/" + filename)}"></script>')
# Media for the Flowbite-datepicker year picker: the vendored UMD bundle # The <year-picker> custom element wraps the Flowbite-datepicker year grid.
# (classic script) plus the ts/year_picker.ts glue (compiled module). Declared # The builder auto-attaches dist/elements/year-picker.js; the vendored UMD
# on the YearPicker node so Page() loads both wherever a YearPicker appears. The # bundle (classic script, runs during parse) is merged in via with_media so
# UMD bundle is a classic script (runs during parse) while year_picker.js is a # Datepicker is defined by the time the deferred element module executes.
# deferred module (runs after parse), so Datepicker is defined by the time the _YearPicker = custom_element_builder("year-picker")
# module executes regardless of tag order. _DATEPICKER_MEDIA = Media(js_external=("datepicker.umd.js",))
_YEAR_PICKER_MEDIA = Media(
js_external=("datepicker.umd.js",), js=("dist/year_picker.js",) # The down-chevron rendered inside the YearPicker button. Trusted static SVG.
_YEAR_PICKER_CHEVRON = Safe(
'<svg class="w-4 h-4 ms-2 rtl:rotate-180" aria-hidden="true" '
'xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 10">'
'<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" '
'stroke-width="2" d="M1 5h12m0 0L9 1m4 4L9 9"/></svg>'
) )
@@ -574,8 +579,10 @@ def YearPicker(
placeholder, substituted with the chosen year in JS (keeps this component placeholder, substituted with the chosen year in JS (keeps this component
decoupled from the project's URL names). decoupled from the project's URL names).
The Flowbite-datepicker UMD bundle is declared as ``media`` on the returned Behavior lives in ``ts/elements/year-picker.ts``; this renders the light
node, so ``Page()`` loads it automatically. 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" label = str(year) if year is not None else "Choose a year"
selected = str(year) if year is not None else "" 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" "hover:bg-neutral-tertiary-medium focus:ring-4 focus:ring-brand-medium"
) )
years_csv = ",".join(str(y) for y in available_years) years_csv = ",".join(str(y) for y in available_years)
return Safe( return _YearPicker(
f"""<div class="relative inline-block" x-data="{{ pickerOpen: false }}" attributes=[
@keydown.escape.window="pickerOpen = false"> ("selected-year", selected),
<button type="button" ("available-years", years_csv),
x-on:click="pickerOpen = !pickerOpen; $refs.pickerInput._pickerInstance && ($refs.pickerInput._pickerInstance.active ? $refs.pickerInput._pickerInstance.hide() : $refs.pickerInput._pickerInstance.show())" ("url-template", url_template),
class="inline-flex items-center rounded-base px-4 py-2 text-sm font-medium {classes}"> ("class", "relative inline-block"),
{label} ],
<svg class="w-4 h-4 ms-2 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 10"> children=[
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 5h12m0 0L9 1m4 4L9 9"/> Element(
</svg> "button",
</button> attributes=[
<input type="text" x-ref="pickerInput" id="year-picker-input" ("type", "button"),
class="absolute opacity-0 pointer-events-none" ("data-year-picker-toggle", ""),
style="width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0;" (
data-available-years="{years_csv}" "class",
data-selected-year="{selected}" "inline-flex items-center rounded-base px-4 py-2 "
data-url-template="{url_template}"> f"text-sm font-medium {classes}",
</div>""", ),
media=_YEAR_PICKER_MEDIA, ],
) 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 # Form-field rendering. The element classes (label/error/checkbox-row + the
+7 -15
View File
@@ -20,6 +20,7 @@
* NB: class strings below are emitted verbatim so the Tailwind scanner picks * NB: class strings below are emitted verbatim so the Tailwind scanner picks
* them up — keep them as plain literals. * them up — keep them as plain literals.
*/ */
import { bindPopupDismiss } from "../utils.js";
type Anchor = "" | "start" | "end"; type Anchor = "" | "start" | "end";
@@ -517,22 +518,13 @@ function createCalendarState(
closePopup(); closePopup();
}); });
const onKeyDown = (event: KeyboardEvent): void => { const cleanup = bindPopupDismiss({
if (event.key === "Escape" && state.open) closePopup(); host: picker,
}; isOpen: () => state.open,
const onMouseDown = (event: MouseEvent): void => { close: closePopup,
if (state.open && !picker.contains(event.target as Node)) closePopup(); });
};
document.addEventListener("keydown", onKeyDown);
document.addEventListener("mousedown", onMouseDown);
return { return { state, cleanup };
state,
cleanup() {
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("mousedown", onMouseDown);
},
};
} }
function initPicker(picker: HTMLElement): () => void { function initPicker(picker: HTMLElement): () => void {
+10
View File
@@ -403,6 +403,16 @@ class FilterBarElement extends HTMLElement {
const form = this.querySelector<HTMLFormElement>("form"); const form = this.querySelector<HTMLFormElement>("form");
if (!form) return; 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) => { form.addEventListener("submit", (event) => {
event.preventDefault(); event.preventDefault();
const filter = buildFilterJSON(form); const filter = buildFilterJSON(form);
+81
View File
@@ -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 <button> plus a hidden #year-picker-input and
* carries selected-year / available-years / url-template as typed props. This
* turns the input into a year-level Datepicker, toggles it from the button, and
* navigates to the chosen year's URL. Datepicker comes from the vendored UMD
* bundle (datepicker.umd.js), a classic script loaded before this module runs.
*
* The Datepicker popup is appended to document.body, so its built-in
* outside-click handler is bypassed (it only fires when the input is focused,
* and our input is unfocusable). bindPopupDismiss handles Escape + outside
* click instead, treating the body-mounted popup as "inside".
*/
import { readYearPickerProps } from "../generated/props.js";
import { bindPopupDismiss } from "../utils.js";
declare const Datepicker: any;
class YearPickerElement extends HTMLElement {
private cleanup: (() => void) | null = null;
connectedCallback(): void {
const { selectedYear, availableYears, urlTemplate } = readYearPickerProps(this);
const input = this.querySelector<HTMLInputElement>("#year-picker-input");
const toggle = this.querySelector<HTMLElement>("[data-year-picker-toggle]");
if (!input || !toggle) return;
const currentYear = new Date().getFullYear();
const enabledYears = new Set(
availableYears
.split(",")
.map((part) => parseInt(part.trim(), 10))
.filter((year) => !isNaN(year))
);
const picker = new Datepicker(input, {
pickLevel: 2,
format: "yyyy",
minDate: new Date(1999, 0, 1),
maxDate: new Date(currentYear, 11, 31),
autohide: false,
orientation: "bottom end",
showOnClick: false,
showOnFocus: false,
beforeShowYear: (date: Date) => ({ enabled: enabledYears.has(date.getFullYear()) }),
});
picker.element.addEventListener("changeDate", (event: Event) => {
const year = (event as CustomEvent).detail.date?.getFullYear();
if (year && urlTemplate) {
window.location.href = urlTemplate.replace("__year__", String(year));
}
});
if (selectedYear) {
picker.dates = [new Date(parseInt(selectedYear, 10), 0, 1)];
picker.update();
}
toggle.addEventListener("click", () => {
if (picker.active) picker.hide();
else picker.show();
});
this.cleanup = bindPopupDismiss({
host: this,
isOpen: () => picker.active,
close: () => picker.hide(),
extraInside: () => [picker.picker?.element],
});
}
disconnectedCallback(): void {
this.cleanup?.();
this.cleanup = null;
}
}
customElements.define("year-picker", YearPickerElement);
+38
View File
@@ -100,6 +100,43 @@ function getValueFromProperty(sourceElement: EventTarget, property: string): any
} }
} }
interface PopupDismissOptions {
// Clicks within host (or any extraInside root) do not dismiss.
host: HTMLElement;
isOpen: () => boolean;
close: () => void;
// Extra roots considered "inside" — e.g. a library popup appended to
// document.body rather than nested under host. Evaluated per event so a
// lazily-created popup element is picked up once it exists.
extraInside?: () => Array<Element | null | undefined>;
}
/**
* Wires the shared dismiss behaviour for an anchored popup: Escape closes it,
* and a mousedown outside the host (and any extraInside roots) closes it. Only
* acts while isOpen() is true. Returns a cleanup function that removes both
* document listeners — call it from disconnectedCallback.
*/
function bindPopupDismiss(options: PopupDismissOptions): () => void {
const isInside = (target: Node): boolean => {
if (options.host.contains(target)) return true;
const extras = options.extraInside ? options.extraInside() : [];
return extras.some((root) => !!root && root.contains(target));
};
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === "Escape" && options.isOpen()) options.close();
};
const onMouseDown = (event: MouseEvent): void => {
if (options.isOpen() && !isInside(event.target as Node)) options.close();
};
document.addEventListener("keydown", onKeyDown);
document.addEventListener("mousedown", onMouseDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("mousedown", onMouseDown);
};
}
type ElementHandlerConfig = [ type ElementHandlerConfig = [
condition: () => boolean, // condition function condition: () => boolean, // condition function
targetElements: string[], // array of target element selectors targetElements: string[], // array of target element selectors
@@ -207,4 +244,5 @@ export {
disableElementsWhenValueNotEqual, disableElementsWhenValueNotEqual,
disableElementsWhenTrue, disableElementsWhenTrue,
getValueFromProperty, getValueFromProperty,
bindPopupDismiss,
}; };
-54
View File
@@ -1,54 +0,0 @@
/**
* YearPicker — wires the Flowbite-datepicker year grid behind the YearPicker
* component (common/components/primitives.py). The component renders a hidden
* #year-picker-input carrying data-available-years / data-selected-year /
* data-url-template; this turns it into a year-level Datepicker and navigates
* to the chosen year's URL. Datepicker comes from the vendored UMD bundle
* (datepicker.umd.js), loaded as a classic script before this module runs.
*/
import { onSwap } from "./utils.js";
declare const Datepicker: any;
// The Alpine toggle button reaches the Datepicker instance through this prop.
interface PickerElement extends HTMLInputElement {
_pickerInstance?: any;
}
onSwap("#year-picker-input", (element) => {
const pickerElement = element as PickerElement;
const selectedYear = pickerElement.dataset.selectedYear;
const urlTemplate = pickerElement.dataset.urlTemplate;
const currentYear = new Date().getFullYear();
const availableYears = new Set(
(pickerElement.dataset.availableYears ?? "")
.split(",")
.map((part) => parseInt(part.trim(), 10))
.filter((year) => !isNaN(year))
);
const picker = new Datepicker(pickerElement, {
pickLevel: 2,
format: "yyyy",
minDate: new Date(1999, 0, 1),
maxDate: new Date(currentYear, 11, 31),
autohide: false,
orientation: "bottom end",
showOnClick: false,
showOnFocus: false,
beforeShowYear: (date: Date) => ({ enabled: availableYears.has(date.getFullYear()) }),
});
pickerElement._pickerInstance = picker;
picker.element.addEventListener("changeDate", (event: Event) => {
const year = (event as CustomEvent).detail.date?.getFullYear();
if (year && urlTemplate) {
window.location.href = urlTemplate.replace("__year__", String(year));
}
});
if (selectedYear) {
picker.dates = [new Date(parseInt(selectedYear, 10), 0, 1)];
picker.update();
}
});