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
+7 -15
View File
@@ -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 {
+10
View File
@@ -403,6 +403,16 @@ class FilterBarElement extends HTMLElement {
const form = this.querySelector<HTMLFormElement>("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);
+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 = [
condition: () => boolean, // condition function
targetElements: string[], // array of target element selectors
@@ -207,4 +244,5 @@ export {
disableElementsWhenValueNotEqual,
disableElementsWhenTrue,
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();
}
});