Files
site-gateway/src/public/select-enhance.js
T

122 lines
6.0 KiB
JavaScript

// ============================================================================================
// select-enhance.js -- replaces native <select> popups with a custom-drawn, dark-themed
// listbox (Task #20). The `color-scheme` CSS hint does not reliably theme native select
// popups across real browsers/engines, so this draws its own. The underlying native <select>
// is kept in the DOM, fully intact for its `name`/`value`/form submission and for every
// existing piece of code that reads or sets `form.elements[name].value` or listens for a
// native "change" event -- none of that code needed to change. Only direct user interaction
// with the native popup is replaced.
// ============================================================================================
function enhanceSelects() {
document.querySelectorAll("select").forEach(select => {
if (select.dataset.enhanced) return;
if (select.closest(".custom-select")) return;
select.dataset.enhanced = "1";
const wrap = document.createElement("span");
wrap.className = "custom-select";
select.replaceWith(wrap);
wrap.append(select);
// The native element stays for value/name/form/event-listener compatibility, but is
// removed from the tab order and made unclickable -- the trigger below is what users
// and assistive tech actually interact with.
select.tabIndex = -1;
select.setAttribute("aria-hidden", "true");
const trigger = document.createElement("button");
trigger.type = "button";
trigger.className = "custom-select-trigger";
trigger.setAttribute("role", "combobox");
trigger.setAttribute("aria-haspopup", "listbox");
trigger.setAttribute("aria-expanded", "false");
wrap.append(trigger);
const syncTriggerLabel = () => {
const option = select.options[select.selectedIndex];
trigger.textContent = option ? option.textContent : "";
trigger.disabled = select.disabled;
};
syncTriggerLabel();
let menu = null;
const closeMenu = () => {
if (!menu) return;
menu.remove();
menu = null;
trigger.setAttribute("aria-expanded", "false");
};
const commit = (option, index) => {
select.selectedIndex = index;
select.dispatchEvent(new Event("input", { bubbles: true }));
select.dispatchEvent(new Event("change", { bubbles: true }));
syncTriggerLabel();
closeMenu();
trigger.focus();
};
const openMenu = () => {
if (menu || select.disabled) return;
menu = document.createElement("div");
menu.className = "custom-select-menu";
menu.setAttribute("role", "listbox");
const rect = trigger.getBoundingClientRect();
menu.style.left = `${rect.left}px`;
menu.style.top = `${rect.bottom + 4}px`;
menu.style.width = `${rect.width}px`;
[...select.options].forEach((option, index) => {
const item = document.createElement("div");
item.className = "custom-select-option" + (index === select.selectedIndex ? " is-selected" : "") + (option.disabled ? " is-disabled" : "");
item.setAttribute("role", "option");
item.textContent = option.textContent;
if (option.disabled) item.setAttribute("aria-disabled", "true");
else item.addEventListener("click", () => commit(option, index));
menu.append(item);
});
// Dialogs render in the browser's top layer, which sits above ordinary DOM regardless
// of z-index -- a menu appended to <body> for a select inside a <dialog> would render
// beneath it. Appending into the dialog keeps the menu in the same stacking context.
(select.closest("dialog") || document.body).append(menu);
trigger.setAttribute("aria-expanded", "true");
const highlighted = () => menu?.querySelector(".is-highlighted") || menu?.querySelector(".is-selected") || menu?.firstElementChild;
menu.querySelector(".is-selected")?.classList.add("is-highlighted");
menu._moveHighlight = delta => {
const items = [...menu.querySelectorAll(".custom-select-option:not(.is-disabled)")];
if (!items.length) return;
const current = menu.querySelector(".is-highlighted");
let index = current ? items.indexOf(current) : -1;
index = (index + delta + items.length) % items.length;
menu.querySelectorAll(".is-highlighted").forEach(item => item.classList.remove("is-highlighted"));
items[index].classList.add("is-highlighted");
items[index].scrollIntoView({ block: "nearest" });
};
menu._chooseHighlighted = () => {
const item = highlighted();
if (!item) return;
const index = [...menu.children].indexOf(item);
if (index >= 0 && !select.options[index]?.disabled) commit(select.options[index], index);
};
};
trigger.addEventListener("click", () => (menu ? closeMenu() : openMenu()));
trigger.addEventListener("keydown", event => {
if (["ArrowDown", "ArrowUp", "Enter", " "].includes(event.key)) event.preventDefault();
if (event.key === "ArrowDown") { if (!menu) openMenu(); else menu._moveHighlight(1); }
else if (event.key === "ArrowUp") { if (!menu) openMenu(); else menu._moveHighlight(-1); }
else if (event.key === "Enter" || event.key === " ") { if (!menu) openMenu(); else menu._chooseHighlighted(); }
else if (event.key === "Escape") closeMenu();
else if (event.key === "Tab") closeMenu();
});
document.addEventListener("click", event => { if (menu && !wrap.contains(event.target) && !menu.contains(event.target)) closeMenu(); }, true);
wrap.__syncTriggerLabel = syncTriggerLabel;
});
// Keep every already-enhanced trigger's label in sync with code elsewhere that sets
// `select.value`/`select.selectedIndex` directly (e.g. renderBackups() populating the
// scheduled-backup form from saved settings) without going through the custom menu.
document.querySelectorAll(".custom-select").forEach(wrap => wrap.__syncTriggerLabel?.());
}
document.addEventListener("DOMContentLoaded", enhanceSelects);
setInterval(enhanceSelects, 150);