/* * 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 }); }