Files
rustpad/static/js/select.js
T
2026-09-07 15:01:58 +02:00

315 lines
11 KiB
JavaScript

/*
* Copyright (C) 2026 Mateusz Gruszczynski @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
const states = new WeakMap();
let initialized = false;
let openState = null;
let observer = null;
function selectLabel(select) {
const direct = select.getAttribute("aria-label");
if (direct) return direct.trim();
if (select.id) {
const label = document.querySelector(`label[for="${CSS.escape(select.id)}"]`);
const text = label?.textContent?.trim();
if (text) return text;
}
const label = select.closest("label");
if (label) {
const clone = label.cloneNode(true);
clone.querySelectorAll("select, .app-select").forEach(node => node.remove());
const text = clone.textContent?.trim();
if (text) return text;
}
return select.name || select.id || "Select option";
}
function optionEntries(select) {
return [...select.options].map((option, index) => ({ option, index }));
}
function closeSelect(state, { focusTrigger = false } = {}) {
if (!state?.open) return;
state.open = false;
state.wrapper.classList.remove("is-open");
state.trigger.setAttribute("aria-expanded", "false");
state.menu.hidden = true;
if (openState === state) openState = null;
if (focusTrigger) state.trigger.focus();
}
function closeOtherSelects(except = null) {
if (openState && openState !== except) closeSelect(openState);
document.querySelectorAll(".app-select--header[open]").forEach(details => {
if (details !== except?.wrapper) details.open = false;
});
}
function menuRoot(select) {
return select.closest("dialog") || document.body;
}
function positionMenu(state) {
if (!state.open || state.menu.hidden || !state.trigger.isConnected) return;
const rect = state.trigger.getBoundingClientRect();
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
const gap = 6;
const edge = 8;
state.menu.style.minWidth = `${Math.ceil(rect.width)}px`;
state.menu.style.maxWidth = `${Math.max(180, viewportWidth - edge * 2)}px`;
state.menu.style.left = `${Math.max(edge, Math.min(rect.left, viewportWidth - state.menu.offsetWidth - edge))}px`;
const menuHeight = state.menu.offsetHeight;
const roomBelow = viewportHeight - rect.bottom - edge;
const roomAbove = rect.top - edge;
const openAbove = roomBelow < Math.min(menuHeight, 220) && roomAbove > roomBelow;
const top = openAbove
? Math.max(edge, rect.top - menuHeight - gap)
: Math.min(viewportHeight - menuHeight - edge, rect.bottom + gap);
state.menu.style.top = `${Math.max(edge, top)}px`;
}
function focusOption(state, direction = 1) {
const buttons = [...state.menu.querySelectorAll(".app-select-option:not(:disabled)")];
if (!buttons.length) return;
const current = document.activeElement;
const currentIndex = buttons.indexOf(current);
const selectedIndex = buttons.findIndex(button => button.getAttribute("aria-selected") === "true");
const base = currentIndex >= 0 ? currentIndex : selectedIndex;
const next = base < 0
? (direction > 0 ? 0 : buttons.length - 1)
: (base + direction + buttons.length) % buttons.length;
buttons[next].focus();
}
function renderOptions(state) {
const { select, menu } = state;
const fragment = document.createDocumentFragment();
for (const { option, index } of optionEntries(select)) {
const button = document.createElement("button");
button.type = "button";
button.className = "app-select-option";
button.dataset.optionIndex = String(index);
button.setAttribute("role", "option");
button.setAttribute("aria-selected", option.selected ? "true" : "false");
button.disabled = option.disabled || Boolean(option.parentElement?.disabled);
const check = document.createElement("span");
check.className = "app-select-option__check";
check.setAttribute("aria-hidden", "true");
check.textContent = option.selected ? "✓" : "";
const copy = document.createElement("span");
copy.className = "app-select-option__copy";
copy.textContent = option.label || option.textContent || option.value;
if (option.parentElement instanceof HTMLOptGroupElement) {
const group = document.createElement("span");
group.className = "app-select-option__group";
group.textContent = option.parentElement.label;
copy.prepend(group);
}
button.append(check, copy);
fragment.append(button);
}
menu.replaceChildren(fragment);
}
function syncSelect(state) {
const { select, trigger, value, menu, wrapper } = state;
const selected = select.selectedOptions[0] || select.options[select.selectedIndex] || select.options[0];
value.textContent = selected?.label || selected?.textContent || selected?.value || "";
trigger.disabled = select.disabled;
trigger.setAttribute("aria-disabled", select.disabled ? "true" : "false");
trigger.setAttribute("aria-label", `${selectLabel(select)}: ${value.textContent}`);
wrapper.classList.toggle("is-disabled", select.disabled);
renderOptions(state);
if (state.open) requestAnimationFrame(() => positionMenu(state));
}
function openSelect(state, { focusSelected = false } = {}) {
if (state.select.disabled || state.open) return;
closeOtherSelects(state);
state.open = true;
openState = state;
state.wrapper.classList.add("is-open");
state.trigger.setAttribute("aria-expanded", "true");
state.menu.hidden = false;
syncSelect(state);
positionMenu(state);
if (focusSelected) {
const selected = state.menu.querySelector('.app-select-option[aria-selected="true"]:not(:disabled)')
|| state.menu.querySelector(".app-select-option:not(:disabled)");
selected?.focus();
}
}
function toggleSelect(state) {
if (state.open) closeSelect(state);
else openSelect(state);
}
function enhanceSelect(select) {
if (!(select instanceof HTMLSelectElement)) return;
if (select.dataset.appSelectEnhanced === "true") return;
if (select.multiple || Number(select.size || 0) > 1 || select.dataset.appSelect === "native") return;
const wrapper = document.createElement("span");
wrapper.className = "app-select app-select--field";
const trigger = document.createElement("button");
trigger.type = "button";
trigger.className = "app-select-trigger";
trigger.setAttribute("aria-haspopup", "listbox");
trigger.setAttribute("aria-expanded", "false");
const value = document.createElement("span");
value.className = "app-select-trigger__value";
const chevron = document.createElement("span");
chevron.className = "app-select-trigger__chevron";
chevron.setAttribute("aria-hidden", "true");
chevron.textContent = "▾";
const menu = document.createElement("div");
menu.className = "app-select-menu app-select-menu--portal";
menu.setAttribute("role", "listbox");
menu.hidden = true;
trigger.append(value, chevron);
wrapper.append(trigger);
const rect = select.getBoundingClientRect();
if (rect.width) wrapper.style.minWidth = `${Math.ceil(rect.width)}px`;
select.insertAdjacentElement("afterend", wrapper);
menuRoot(select).append(menu);
select.dataset.appSelectEnhanced = "true";
select.classList.add("app-select-native");
select.tabIndex = -1;
const state = { select, wrapper, trigger, value, menu, open: false };
states.set(select, state);
trigger.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
toggleSelect(state);
});
trigger.addEventListener("keydown", event => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
openSelect(state, { focusSelected: true });
if (event.key === "ArrowUp") focusOption(state, -1);
}
});
menu.addEventListener("click", event => {
const button = event.target.closest(".app-select-option");
if (!(button instanceof HTMLButtonElement) || button.disabled) return;
event.preventDefault();
event.stopPropagation();
const index = Number(button.dataset.optionIndex);
if (!Number.isInteger(index) || !select.options[index]) return;
const changed = select.selectedIndex !== index;
select.selectedIndex = index;
syncSelect(state);
closeSelect(state, { focusTrigger: true });
if (changed) {
select.dispatchEvent(new Event("input", { bubbles: true }));
select.dispatchEvent(new Event("change", { bubbles: true }));
}
});
menu.addEventListener("keydown", event => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
focusOption(state, event.key === "ArrowDown" ? 1 : -1);
return;
}
if (event.key === "Home" || event.key === "End") {
event.preventDefault();
const buttons = [...menu.querySelectorAll(".app-select-option:not(:disabled)")];
(event.key === "Home" ? buttons[0] : buttons.at(-1))?.focus();
return;
}
if (event.key === "Escape" || event.key === "Tab") {
closeSelect(state, { focusTrigger: event.key === "Escape" });
}
});
select.addEventListener("change", () => syncSelect(state));
select.addEventListener("rustpad:select-sync", () => syncSelect(state));
syncSelect(state);
}
function scan(root = document) {
if (root instanceof HTMLSelectElement) enhanceSelect(root);
root.querySelectorAll?.("select").forEach(enhanceSelect);
}
function syncMutation(mutation) {
const target = mutation.target instanceof Element ? mutation.target : mutation.target?.parentElement;
const select = target?.closest?.("select");
const state = select ? states.get(select) : null;
if (state) syncSelect(state);
}
export function syncSelectControl(select) {
const state = states.get(select);
if (state) syncSelect(state);
}
export function initSelectControls() {
scan(document);
if (initialized) return;
initialized = true;
observer = new MutationObserver(mutations => {
for (const mutation of mutations) {
mutation.addedNodes.forEach(node => {
if (node instanceof Element) scan(node);
});
if (mutation.type === "attributes" || mutation.target instanceof HTMLOptionElement || mutation.target instanceof HTMLOptGroupElement) syncMutation(mutation);
}
});
observer.observe(document.documentElement, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["disabled", "aria-label", "label"],
});
document.addEventListener("pointerdown", event => {
const target = event.target instanceof Element ? event.target : null;
if (openState && !openState.wrapper.contains(target) && !openState.menu.contains(target)) closeSelect(openState);
}, { passive: true });
document.addEventListener("keydown", event => {
if (event.key === "Escape" && openState) closeSelect(openState, { focusTrigger: true });
});
window.addEventListener("resize", () => {
if (openState) positionMenu(openState);
}, { passive: true });
document.addEventListener("scroll", () => {
if (openState) positionMenu(openState);
}, { capture: true, passive: true });
}