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:
@@ -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,
|
||||||
|
|||||||
@@ -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 "
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user