217 lines
5.9 KiB
JavaScript
217 lines
5.9 KiB
JavaScript
const closeEventName = "nodedc-select-open";
|
|
|
|
const makeSelectId = () => `ndc-select-${Math.random().toString(16).slice(2)}`;
|
|
|
|
export function createGlassSelect({
|
|
host,
|
|
value,
|
|
options = [],
|
|
ariaLabel = "Выбрать",
|
|
disabled = false,
|
|
onChange = null,
|
|
}) {
|
|
if (!host) return null;
|
|
|
|
const selectId = makeSelectId();
|
|
let currentValue = value;
|
|
let currentOptions = [...options];
|
|
let open = false;
|
|
let menu = null;
|
|
let menuRect = { top: 0, left: 0, width: 0 };
|
|
|
|
host.innerHTML = "";
|
|
|
|
const root = document.createElement("div");
|
|
root.className = "nodedc-select";
|
|
|
|
const control = document.createElement("div");
|
|
control.className = "nodedc-select__control";
|
|
|
|
const valueEl = document.createElement("div");
|
|
valueEl.className = "nodedc-select__value";
|
|
|
|
const toggle = document.createElement("button");
|
|
toggle.type = "button";
|
|
toggle.className = "nodedc-select__toggle";
|
|
toggle.setAttribute("aria-label", ariaLabel);
|
|
toggle.setAttribute("aria-haspopup", "listbox");
|
|
toggle.disabled = !!disabled;
|
|
|
|
const chevron = document.createElement("span");
|
|
chevron.className = "nodedc-select__chevron";
|
|
chevron.setAttribute("aria-hidden", "true");
|
|
toggle.appendChild(chevron);
|
|
|
|
control.appendChild(valueEl);
|
|
control.appendChild(toggle);
|
|
root.appendChild(control);
|
|
host.appendChild(root);
|
|
|
|
const selectedOption = () => (
|
|
currentOptions.find((option) => option.value === currentValue) ||
|
|
currentOptions[0] ||
|
|
null
|
|
);
|
|
|
|
const updateValue = () => {
|
|
const selected = selectedOption();
|
|
valueEl.textContent = selected?.label || "";
|
|
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
|
};
|
|
|
|
const updateMenuRect = () => {
|
|
const rect = root.getBoundingClientRect();
|
|
const toggleWidth = 42;
|
|
const gap = 8;
|
|
const menuMaxHeight = 232;
|
|
const bottomTop = rect.bottom + 6;
|
|
const top = bottomTop + menuMaxHeight > window.innerHeight - 12
|
|
? Math.max(12, rect.top - menuMaxHeight - 6)
|
|
: bottomTop;
|
|
|
|
menuRect = {
|
|
top,
|
|
left: rect.left,
|
|
width: Math.max(180, rect.width - toggleWidth - gap),
|
|
};
|
|
|
|
if (menu) {
|
|
menu.style.top = `${menuRect.top}px`;
|
|
menu.style.left = `${menuRect.left}px`;
|
|
menu.style.width = `${menuRect.width}px`;
|
|
}
|
|
};
|
|
|
|
const renderMenuOptions = () => {
|
|
if (!menu) return;
|
|
menu.innerHTML = "";
|
|
currentOptions.forEach((option) => {
|
|
const item = document.createElement("button");
|
|
item.type = "button";
|
|
item.className = "nodedc-dropdown-option nodedc-select__option";
|
|
item.dataset.value = option.value;
|
|
if (option.value === currentValue) {
|
|
item.dataset.selected = "true";
|
|
}
|
|
item.setAttribute("role", "option");
|
|
item.setAttribute("aria-selected", option.value === currentValue ? "true" : "false");
|
|
item.textContent = option.label;
|
|
item.addEventListener("click", () => {
|
|
setValue(option.value, { emit: true });
|
|
closeMenu();
|
|
});
|
|
menu.appendChild(item);
|
|
});
|
|
};
|
|
|
|
const openMenu = () => {
|
|
if (toggle.disabled || open) return;
|
|
open = true;
|
|
updateValue();
|
|
updateMenuRect();
|
|
window.dispatchEvent(new CustomEvent(closeEventName, { detail: { id: selectId } }));
|
|
|
|
menu = document.createElement("div");
|
|
menu.className = "nodedc-dropdown-surface nodedc-select__menu";
|
|
menu.setAttribute("role", "listbox");
|
|
menu.style.position = "fixed";
|
|
menu.style.top = `${menuRect.top}px`;
|
|
menu.style.left = `${menuRect.left}px`;
|
|
menu.style.width = `${menuRect.width}px`;
|
|
renderMenuOptions();
|
|
document.body.appendChild(menu);
|
|
|
|
document.addEventListener("mousedown", handleDocumentPointerDown);
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
window.addEventListener(closeEventName, handleOtherSelectOpen);
|
|
window.addEventListener("resize", updateMenuRect);
|
|
window.addEventListener("scroll", updateMenuRect, true);
|
|
};
|
|
|
|
function closeMenu() {
|
|
if (!open) return;
|
|
open = false;
|
|
updateValue();
|
|
if (menu) {
|
|
menu.remove();
|
|
menu = null;
|
|
}
|
|
document.removeEventListener("mousedown", handleDocumentPointerDown);
|
|
document.removeEventListener("keydown", handleKeyDown);
|
|
window.removeEventListener(closeEventName, handleOtherSelectOpen);
|
|
window.removeEventListener("resize", updateMenuRect);
|
|
window.removeEventListener("scroll", updateMenuRect, true);
|
|
}
|
|
|
|
function handleDocumentPointerDown(event) {
|
|
const target = event.target;
|
|
if (
|
|
target instanceof Node &&
|
|
!root.contains(target) &&
|
|
(!menu || !menu.contains(target))
|
|
) {
|
|
closeMenu();
|
|
}
|
|
}
|
|
|
|
function handleKeyDown(event) {
|
|
if (event.key === "Escape") closeMenu();
|
|
}
|
|
|
|
function handleOtherSelectOpen(event) {
|
|
if (event?.detail?.id !== selectId) closeMenu();
|
|
}
|
|
|
|
function setValue(nextValue, { emit = false } = {}) {
|
|
const nextOption = currentOptions.find((option) => option.value === nextValue);
|
|
if (!nextOption) return;
|
|
const changed = currentValue !== nextValue;
|
|
currentValue = nextValue;
|
|
updateValue();
|
|
renderMenuOptions();
|
|
if (emit && changed && typeof onChange === "function") {
|
|
onChange(nextValue);
|
|
}
|
|
}
|
|
|
|
const setOptions = (nextOptions = [], nextValue = currentValue) => {
|
|
currentOptions = [...nextOptions];
|
|
currentValue = currentOptions.some((option) => option.value === nextValue)
|
|
? nextValue
|
|
: currentOptions[0]?.value;
|
|
updateValue();
|
|
renderMenuOptions();
|
|
};
|
|
|
|
const setDisabled = (nextDisabled) => {
|
|
toggle.disabled = !!nextDisabled;
|
|
if (toggle.disabled) closeMenu();
|
|
};
|
|
|
|
const destroy = () => {
|
|
closeMenu();
|
|
root.remove();
|
|
};
|
|
|
|
toggle.addEventListener("click", () => {
|
|
if (open) {
|
|
closeMenu();
|
|
} else {
|
|
openMenu();
|
|
}
|
|
});
|
|
valueEl.addEventListener("click", openMenu);
|
|
|
|
updateValue();
|
|
|
|
return {
|
|
root,
|
|
setValue,
|
|
setOptions,
|
|
setDisabled,
|
|
close: closeMenu,
|
|
destroy,
|
|
getValue: () => currentValue,
|
|
};
|
|
}
|