184749bf6d
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>
112 lines
4.0 KiB
TypeScript
112 lines
4.0 KiB
TypeScript
export interface DropdownConfig {
|
|
patchUrl: string;
|
|
bodyKey: string; // server field name, e.g. "status" or "device_id"
|
|
event: string; // dispatched on document.body after a successful PATCH
|
|
csrf: string;
|
|
numericValue?: boolean; // parse the option value as a number
|
|
}
|
|
|
|
// Wires a light-DOM value-selector dropdown that lives inside `host`.
|
|
// Markup hooks (rendered server-side): [data-toggle], [data-menu],
|
|
// [data-label], and one or more [data-option][data-value].
|
|
export function initDropdown(host: HTMLElement, config: DropdownConfig): void {
|
|
const toggle = host.querySelector<HTMLElement>("[data-toggle]");
|
|
const menu = host.querySelector<HTMLElement>("[data-menu]");
|
|
const label = host.querySelector<HTMLElement>("[data-label]");
|
|
if (!toggle || !menu || !label) return;
|
|
|
|
// The menu lives inside the table's `overflow-x-auto` wrapper, which forces
|
|
// `overflow-y: auto` and clips an absolutely-positioned menu that extends
|
|
// past a short table (issue #39). Position it `fixed` while open so it
|
|
// escapes the clipping ancestor, anchored to the toggle and bounded to the
|
|
// viewport (flipping up when there is more room above).
|
|
const VIEWPORT_MARGIN = 8;
|
|
|
|
const positionMenu = (): void => {
|
|
const rect = toggle.getBoundingClientRect();
|
|
const spaceBelow = window.innerHeight - rect.bottom - VIEWPORT_MARGIN;
|
|
const spaceAbove = rect.top - VIEWPORT_MARGIN;
|
|
const openUp = menu.scrollHeight > spaceBelow && spaceAbove > spaceBelow;
|
|
|
|
menu.style.position = "fixed";
|
|
menu.style.left = `${rect.left}px`;
|
|
menu.style.width = `${rect.width}px`;
|
|
menu.style.maxHeight = `${Math.max(0, openUp ? spaceAbove : spaceBelow)}px`;
|
|
menu.style.overflowY = "auto";
|
|
// Set the unused anchor to "auto" (not "") so this inline value overrides
|
|
// the menu's `top-[105%]` utility class; clearing it to "" would let the
|
|
// class reassert top:105% and collapse the fixed menu off-screen.
|
|
if (openUp) {
|
|
menu.style.top = "auto";
|
|
menu.style.bottom = `${window.innerHeight - rect.top}px`;
|
|
} else {
|
|
menu.style.bottom = "auto";
|
|
menu.style.top = `${rect.bottom}px`;
|
|
}
|
|
};
|
|
|
|
const clearPosition = (): void => {
|
|
for (const property of [
|
|
"position",
|
|
"top",
|
|
"bottom",
|
|
"left",
|
|
"width",
|
|
"max-height",
|
|
"overflow-y",
|
|
]) {
|
|
menu.style.removeProperty(property);
|
|
}
|
|
};
|
|
|
|
const reposition = (): void => {
|
|
if (!menu.hidden) positionMenu();
|
|
};
|
|
|
|
const open = (): void => {
|
|
menu.hidden = false;
|
|
positionMenu();
|
|
// Capture-phase scroll listener so scrolling any ancestor (incl. the table
|
|
// wrapper) keeps the fixed menu anchored to its toggle.
|
|
window.addEventListener("scroll", reposition, true);
|
|
window.addEventListener("resize", reposition);
|
|
};
|
|
|
|
const close = (): void => {
|
|
menu.hidden = true;
|
|
clearPosition();
|
|
window.removeEventListener("scroll", reposition, true);
|
|
window.removeEventListener("resize", reposition);
|
|
};
|
|
|
|
toggle.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
if (menu.hidden) open();
|
|
else close();
|
|
});
|
|
document.addEventListener("click", (event) => {
|
|
if (!host.contains(event.target as Node)) close();
|
|
});
|
|
|
|
host.querySelectorAll<HTMLElement>("[data-option]").forEach((option) => {
|
|
option.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const raw = option.dataset.value ?? "";
|
|
label.innerHTML = option.innerHTML;
|
|
close();
|
|
const body: Record<string, unknown> = {
|
|
[config.bodyKey]: config.numericValue ? Number(raw) : raw,
|
|
};
|
|
window
|
|
.fetchWithHtmxTriggers(config.patchUrl, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json", "X-CSRFToken": config.csrf },
|
|
body: JSON.stringify(body),
|
|
})
|
|
.then(() => document.body.dispatchEvent(new CustomEvent(config.event)))
|
|
.catch(() => console.error("Failed to update", config.patchUrl));
|
|
});
|
|
});
|
|
}
|