@studiocms/ui
Version:
The UI library for StudioCMS. Includes the layouts & components we use to build StudioCMS.
390 lines (389 loc) • 15.9 kB
JavaScript
function loadSearchSelects() {
const CONSTANTS = {
OPTION_HEIGHT: 36,
BORDER_SIZE: 2,
MARGIN: 4,
BADGE_PADDING: 80
};
const getDropdownPosition = (input, optionsCount) => {
const rect = input.getBoundingClientRect();
const dropdownHeight = optionsCount * CONSTANTS.OPTION_HEIGHT + CONSTANTS.BORDER_SIZE + CONSTANTS.MARGIN;
const customRect = {
top: rect.bottom + CONSTANTS.MARGIN,
bottom: rect.bottom + CONSTANTS.MARGIN + dropdownHeight,
left: rect.left,
right: rect.right,
width: rect.width,
x: rect.x,
y: rect.y + rect.height + CONSTANTS.MARGIN,
height: dropdownHeight
};
return {
isAbove: customRect.top >= 0 && customRect.left >= 0 && customRect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && customRect.right <= (window.innerWidth || document.documentElement.clientWidth),
customRect
};
};
const createSelectBadge = (value, label) => {
const badge = document.createElement("span");
badge.classList.add("sui-badge", "primary", "sm", "default", "full", "sui-search-select-badge");
badge.setAttribute("data-value", value);
badge.innerHTML = `${label} <svg style='min-width: 8px' xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 24 24' role="button" tabindex="0"><path fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 18L18 6M6 6l12 12'></path></svg>`;
return badge;
};
const recalculateBadges = (state2, container) => {
const badgeContainer = container.querySelector(".sui-search-select-badge-container");
if (!badgeContainer || !container.input) return;
badgeContainer.innerHTML = "";
const selectedValues = state2.selectedOptionsMap[container.dataset.id] || [];
const allOptions = state2.optionsMap[container.dataset.id] || [];
if (selectedValues.length === 0) {
container.input.placeholder = state2.placeholderMap[container.dataset.id] ?? "";
return;
}
for (const value of selectedValues.sort((a, b) => {
const numA = Number.parseInt(a.match(/\d+/)?.[0] || "0");
const numB = Number.parseInt(b.match(/\d+/)?.[0] || "0");
return numA - numB;
})) {
const option = allOptions.find((opt) => opt.value === value);
if (option) {
const newBadge = createSelectBadge(value, option.label);
badgeContainer.appendChild(newBadge);
}
}
};
const updateLabel = (isMultiple, state2, container) => {
const selectedInput = container?.input;
if (isMultiple) {
recalculateBadges(state2, container);
if (selectedInput) {
selectedInput.placeholder = state2.placeholderMap[container.dataset.id] ?? "";
}
} else {
const selected = container.querySelector(
".sui-search-select-option.selected"
);
if (selected && selectedInput) {
selectedInput.placeholder = selected.innerText.trim();
}
}
};
const updateOptionSelection = (value, container, state2, forceState) => {
const currentSelected = state2.selectedOptionsMap[container.dataset.id] || [];
const isCurrentlySelected = currentSelected.includes(value);
const max = Number.parseInt(container.dataset.multipleMax);
if (!isCurrentlySelected && !Number.isNaN(max) && currentSelected.length >= max) {
return false;
}
const newSelected = isCurrentlySelected ? currentSelected.filter((v) => v !== value) : [...currentSelected, value];
state2.selectedOptionsMap[container.dataset.id] = newSelected;
const option = container.dropdown?.querySelector(
`.sui-search-select-option[data-value='${value}']`
);
if (option) {
option.classList.toggle("selected", forceState ?? !isCurrentlySelected);
if (container?.select) {
container.select.value = option.getAttribute("value");
}
}
const selectedCountEl = container.querySelector(
".sui-search-select-max-span .sui-search-select-select-count"
);
if (selectedCountEl) {
selectedCountEl.innerText = String(newSelected.length);
}
return true;
};
const toggleMultiOption = (id, container, state2) => {
const success = updateOptionSelection(id, container, state2);
if (success) {
recalculateBadges(state2, container);
}
};
const recomputeOptions = (state2, container) => {
const optionElements = container?.dropdown?.querySelectorAll(
".sui-search-select-option"
);
for (const entry of optionElements) {
if (Number.parseInt(entry.dataset.optionIndex) === state2.focusIndex) {
entry.classList.add("focused");
} else {
entry.classList.remove("focused");
}
}
};
const reconstructOptions = (filteredOptions, state2, container) => {
container.dropdown.innerHTML = "";
const selectedValues = state2.selectedOptionsMap[container.dataset.id] || [];
if (filteredOptions.length === 0) {
container.dropdown.innerHTML = '<li class="empty-search-results">No results found</li>';
return;
}
let i = 0;
for (const option of filteredOptions) {
const element = document.createElement("li");
element.classList.add("sui-search-select-option");
if (option.disabled) {
element.classList.add("disabled");
}
if (selectedValues.includes(option.value)) {
element.classList.add("selected");
}
element.role = "option";
element.dataset.optionIndex = i.toString();
element.dataset.value = option.value;
element.textContent = option.label;
container.dropdown?.appendChild(element);
i++;
}
};
const getInteractiveOptions = (container) => {
const allOptions = container?.dropdown?.querySelectorAll(
".sui-search-select-option"
);
return Array.from(allOptions).filter(
(option) => !option.classList.contains("hidden") && !option.classList.contains("disabled") && !option.hasAttribute("disabled")
);
};
const handleContainerMouseDown = (e, state2, container) => {
const target = e.target;
if (!target.closest("input")) {
e.preventDefault();
}
if (container.input?.value.length === 0) {
reconstructOptions(state2.optionsMap[container.dataset.id] ?? [], state2, container);
}
if (target.closest(".sui-search-select-indicator")) {
if (container.dropdown?.parentElement?.classList.contains("active")) {
container.dropdown?.parentElement?.classList.remove("active", "above");
container.input?.blur();
container.input.value = "";
} else {
container.dropdown?.parentElement?.classList.add("active");
container.input?.focus();
container.input.value = "";
}
return;
}
if (target.closest(".sui-search-select-badge-container")) {
container.dropdown?.parentElement?.classList.remove("active", "above");
container.input?.blur();
container.input.value = "";
}
state2.isSelectingOption = true;
setTimeout(() => {
state2.isSelectingOption = false;
}, 0);
if (target.closest(".sui-search-select-badge svg")) {
const value = target.closest(".sui-search-select-badge")?.getAttribute("data-value");
const success = updateOptionSelection(value, container, state2);
if (success) {
recalculateBadges(state2, container);
}
return;
}
const opt = target.closest(".sui-search-select-option");
if (!opt?.dataset.value) return;
if (opt.classList.contains("disabled") || opt.hasAttribute("disabled")) {
container.input?.focus();
return;
}
const isMultiple = state2.isMultipleMap[container.dataset.id];
if (isMultiple) {
const success = updateOptionSelection(opt.dataset.value, container, state2);
if (success) {
updateLabel(true, state2, container);
recalculateBadges(state2, container);
}
} else {
const currentSelected = state2.selectedOptionsMap[container.dataset.id] || [];
for (const value of currentSelected) {
updateOptionSelection(value, container, state2, false);
}
updateOptionSelection(opt.dataset.value, container, state2, true);
updateLabel(false, state2, container);
container.dropdown?.parentElement?.classList.remove("active", "above");
container.input?.blur();
container.input.value = "";
}
};
const handleSelectKeyDown = (e, state2, container) => {
const focusedElement = document.activeElement;
if (e.key === "Escape" || e.key === "Tab") {
container.input?.blur();
container.dropdown?.parentElement?.classList.remove("active", "above");
return;
}
if ((e.key === "Enter" || e.key === " ") && focusedElement?.tagName.toLowerCase() === "svg") {
const badgeElement = focusedElement?.closest(".sui-search-select-badge");
if (badgeElement && state2.isMultipleMap[container?.dataset.id]) {
const badgeValue = badgeElement.getAttribute("data-value");
let nextBadge = badgeElement.previousElementSibling;
if (!nextBadge) {
nextBadge = badgeElement.nextElementSibling;
}
const nextBadgeValue = nextBadge?.getAttribute("data-value");
toggleMultiOption(badgeValue, container, state2);
recalculateBadges(state2, container);
setTimeout(() => {
if (nextBadgeValue) {
const badgeToFocus = container?.querySelector(
`.sui-search-select-badge[data-value="${nextBadgeValue}"] svg`
);
if (badgeToFocus) {
badgeToFocus.focus();
}
}
}, 0);
e.preventDefault();
e.stopImmediatePropagation();
return;
}
}
const interactiveOptions = getInteractiveOptions(container);
const currentInteractiveIndex = interactiveOptions.findIndex(
(option) => option.classList.contains("focused")
);
if (e.key === "ArrowUp" && currentInteractiveIndex > 0) {
state2.focusIndex = Array.from(
container?.dropdown?.querySelectorAll(".sui-search-select-option") || []
).indexOf(interactiveOptions[currentInteractiveIndex - 1]);
recomputeOptions(state2, container);
return;
}
if (e.key === "ArrowDown" && currentInteractiveIndex < interactiveOptions.length - 1) {
state2.focusIndex = Array.from(
container?.dropdown?.querySelectorAll(".sui-search-select-option") || []
).indexOf(interactiveOptions[currentInteractiveIndex + 1]);
recomputeOptions(state2, container);
return;
}
if (e.key === "PageUp") {
state2.focusIndex = Array.from(
container?.dropdown?.querySelectorAll(".sui-search-select-option") || []
).indexOf(interactiveOptions[0]);
recomputeOptions(state2, container);
return;
}
if (e.key === "PageDown") {
state2.focusIndex = Array.from(
container?.dropdown?.querySelectorAll(".sui-search-select-option") || []
).indexOf(interactiveOptions[interactiveOptions.length - 1]);
recomputeOptions(state2, container);
return;
}
if (e.key === "Enter") {
e.preventDefault();
e.stopImmediatePropagation();
const optionElements = container?.dropdown?.querySelectorAll(
".sui-search-select-option"
);
const focusedOption = Array.from(optionElements).find(
(entry) => Number.parseInt(entry.dataset.optionIndex) === state2.focusIndex
);
if (focusedOption && !focusedOption.classList.contains("disabled") && !focusedOption.hasAttribute("disabled")) {
const value = focusedOption.dataset.value;
if (!value) return;
const isMultiple = state2.isMultipleMap[container.dataset.id];
if (isMultiple) {
const success = updateOptionSelection(value, container, state2);
if (success) {
updateLabel(true, state2, container);
recalculateBadges(state2, container);
}
} else {
const currentSelected = state2.selectedOptionsMap[container.dataset.id] || [];
for (const existingValue of currentSelected) {
updateOptionSelection(existingValue, container, state2, false);
}
updateOptionSelection(value, container, state2, true);
updateLabel(false, state2, container);
container.dropdown?.parentElement?.classList.remove("active", "above");
container.input.value = "";
}
}
return;
}
};
const handleInputKeyup = (e, state2, container) => {
if (["Enter", "ArrowUp", "ArrowDown"].includes(e.key)) return;
const value = container.input.value.trim().toLowerCase();
const allOptions = state2.optionsMap[container.dataset.id];
if (value.length === 0) {
reconstructOptions(allOptions, state2, container);
return;
}
const filteredOptions = allOptions?.filter((option) => option.label.toLowerCase().includes(value)) ?? [];
state2.focusIndex = 0;
reconstructOptions(filteredOptions, state2, container);
};
const handleContainerFocusOut = (state2, container) => {
if (state2.isSelectingOption) return;
container.input.value = "";
reconstructOptions(state2.optionsMap[container.dataset.id] ?? [], state2, container);
container.dropdown?.parentElement?.classList.remove("active", "above");
};
const handleContainerFocusIn = (state2, container) => {
const allDropdowns = document.querySelectorAll(".sui-search-select-dropdown-list");
for (const dropdown of allDropdowns) {
if (dropdown !== container.dropdown) {
dropdown.parentElement?.classList.remove("active", "above");
}
}
const { isAbove } = getDropdownPosition(
container.input,
state2.optionsMap[container.dataset.id]?.length ?? 0
);
container.dropdown?.parentElement?.classList.add("active", ...isAbove ? [] : ["above"]);
};
const state = {
optionsMap: {},
isMultipleMap: {},
placeholderMap: {},
selectedOptionsMap: {},
focusIndex: 0,
isSelectingOption: false
};
const selects = document.querySelectorAll(".sui-search-select-label");
for (const container of selects) {
if (container.dataset.initialized === "true") continue;
const id = container.dataset.id;
const specialContainer = Object.assign(container, {
input: container.querySelector("input"),
dropdown: container.querySelector(".sui-search-select-dropdown-list"),
select: container.querySelector("select")
});
const selectedOptions = Array.from(
specialContainer.dropdown?.querySelectorAll(".sui-search-select-option.selected") ?? []
);
state.placeholderMap[id] = specialContainer.input?.placeholder ?? "";
state.optionsMap[id] = JSON.parse(container.dataset.options ?? "{}");
state.isMultipleMap[id] = container.dataset.multiple === "true";
state.selectedOptionsMap[id] = selectedOptions.map((x) => x.getAttribute("data-value") ?? "");
specialContainer.input?.addEventListener(
"focusin",
() => handleContainerFocusIn(state, specialContainer)
);
specialContainer.addEventListener(
"focusout",
() => handleContainerFocusOut(state, specialContainer)
);
specialContainer.addEventListener(
"keydown",
(e) => handleSelectKeyDown(e, state, specialContainer)
);
specialContainer.input?.addEventListener(
"keyup",
(e) => handleInputKeyup(e, state, specialContainer)
);
specialContainer.addEventListener(
"mousedown",
(e) => handleContainerMouseDown(e, state, specialContainer)
);
if (state.isMultipleMap[id]) {
recalculateBadges(state, specialContainer);
}
container.dataset.initialized = "true";
}
}
document.addEventListener("astro:page-load", loadSearchSelects);