315 lines
11 KiB
JavaScript
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 });
|
|
}
|