@studiocms/ui
Version:
The UI library for StudioCMS. Includes the layouts & components we use to build StudioCMS.
350 lines (349 loc) • 14 kB
JavaScript
function loadSelects() {
const CONSTANTS = {
OPTION_HEIGHT: 36,
BORDER_SIZE: 2,
MARGIN: 4,
BADGE_PADDING: 80
};
const observerMap = /* @__PURE__ */ new WeakMap();
function observeResize(element, callback) {
if (observerMap.has(element)) {
unobserveResize(element);
}
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
callback(width, height, entry.target);
}
});
observer.observe(element);
observerMap.set(element, { observer, callback });
return () => unobserveResize(element);
}
function unobserveResize(element) {
const data = observerMap.get(element);
if (data) {
data.observer.disconnect();
observerMap.delete(element);
}
}
const isVisible = (elem) => elem.offsetWidth > 0 || elem.offsetHeight > 0 || elem.getClientRects().length > 0;
const getDropdownPosition = (button, optionsCount) => {
const rect = button.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 closeDropdown = (container) => {
if (!container?.button || !container?.dropdown) return;
container.dropdown.classList.remove("active", "above");
container.button.ariaExpanded = "false";
};
const openDropdown = (state2, container) => {
if (!container?.button || !container?.dropdown) return;
const { isAbove } = getDropdownPosition(
container.button,
state2.optionsMap[container.dataset.id]?.length ?? 0
);
container.button.ariaExpanded = "true";
container.dropdown.classList.add("active", ...isAbove ? [] : ["above"]);
};
const createSelectBadge = (value, label) => {
const badge = document.createElement("span");
badge.classList.add("sui-badge", "primary", "sm", "default", "full", "sui-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'><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 measureBadgesWidth = (activeSelects) => {
const tempContainer = document.createElement("div");
tempContainer.classList.add("sui-select-badge-container");
tempContainer.style.position = "absolute";
tempContainer.style.visibility = "hidden";
document.body.appendChild(tempContainer);
const badges = Array.from(activeSelects).map(
(select) => createSelectBadge(
select.getAttribute("value") ?? "",
select.innerText.trim()
)
);
for (const badge of badges) {
tempContainer.appendChild(badge);
}
const totalWidth = badges.reduce((width, badge) => {
const badgeStyle = window.getComputedStyle(badge);
return width + badge.offsetWidth + (Number.parseFloat(badgeStyle.marginLeft) || 0) + (Number.parseFloat(badgeStyle.marginRight) || 0);
}, 0);
return { totalWidth, badges, tempContainer };
};
const handleBadgeOverflow = (state2, container) => {
const buttonContainer = container?.button?.parentElement?.parentElement;
const buttonValueSpan = container?.button?.querySelector(
".sui-select-value-span"
);
const activeSelects = container?.dropdown?.querySelectorAll(".sui-select-option.selected");
const overflowContainer = container?.querySelector(".sui-select-badge-container-below");
if (!buttonContainer || !overflowContainer || !container?.button || !activeSelects) return;
const parentContainer = buttonContainer.parentElement;
if (!parentContainer) return;
overflowContainer.innerHTML = "";
buttonValueSpan.innerHTML = "";
if (activeSelects.length === 0) {
buttonValueSpan.innerText = state2.placeholder;
return;
}
const { totalWidth, badges, tempContainer } = measureBadgesWidth(activeSelects);
const parentStyles = window.getComputedStyle(parentContainer);
const availableWidth = parentContainer.clientWidth - (Number.parseFloat(parentStyles.paddingLeft) || 0) - (Number.parseFloat(parentStyles.paddingRight) || 0);
const effectiveAvailableWidth = availableWidth - CONSTANTS.BADGE_PADDING;
document.body.removeChild(tempContainer);
const finalBadgeContainer = document.createElement("div");
finalBadgeContainer.classList.add("sui-select-badge-container");
for (const badge of badges) {
badge.querySelector("svg")?.setAttribute("tabindex", "0");
finalBadgeContainer.appendChild(badge.cloneNode(true));
}
if (totalWidth > effectiveAvailableWidth) {
overflowContainer.appendChild(finalBadgeContainer);
} else {
buttonValueSpan.appendChild(finalBadgeContainer);
}
};
const updateLabel = (state2, container) => {
const isMultiple = state2.isMultipleMap[container?.dataset.id];
if (isMultiple) {
handleBadgeOverflow(state2, container);
} else {
const selected = container?.querySelector(".sui-select-option.selected");
const selectedButtonSpan = container?.button?.querySelector(
".sui-select-value-span"
);
if (selected && selectedButtonSpan) {
selectedButtonSpan.innerText = selected.innerText.trim();
}
}
};
const deselectMultiOption = (state2, id, container) => {
const selectOpt = container?.dropdown?.querySelector(
`.sui-select-option[value='${id}']`
);
const max = Number.parseInt(container?.dataset.multipleMax);
const selectedCount = container?.querySelectorAll(".sui-select-option.selected").length ?? 0;
const selectedCountEl = container?.querySelector(
".sui-select-max-span .sui-select-select-count"
);
const isSelected = selectOpt?.classList.contains("selected");
if (selectOpt && (isSelected || Number.isNaN(max) || selectedCount < max)) {
selectOpt.classList.toggle("selected");
const selectOptEl = container?.select?.querySelector(
`option[value='${selectOpt.getAttribute("value")}']`
);
if (selectOptEl) {
selectOptEl.selected = !selectOpt.selected;
}
if (selectedCountEl) {
selectedCountEl.innerText = String(selectedCount + (isSelected ? -1 : 1));
}
updateLabel(state2, container);
}
};
const recomputeOptions = (state2, container) => {
const optionElements = container?.dropdown?.querySelectorAll(
".sui-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 getInteractiveOptions = (container) => {
const allOptions = container?.dropdown?.querySelectorAll(
".sui-select-option"
);
return Array.from(allOptions).filter(
(option) => !option.classList.contains("hidden") && !option.classList.contains("disabled") && !option.hasAttribute("disabled")
);
};
const handleOptionSelect = (target, state2, container) => {
const option = target.closest(".sui-select-option");
const lastActive = container?.dropdown?.querySelector(".sui-select-option.selected");
const isMultiple = state2.isMultipleMap[container.dataset.id];
if (isMultiple) {
deselectMultiOption(state2, option?.getAttribute("value"), container);
} else {
if (lastActive) {
lastActive.classList.remove("selected");
}
if (option) {
option.classList.add("selected");
if (container?.select) {
container.select.value = option.getAttribute("value");
}
updateLabel(state2, container);
}
closeDropdown(container);
}
};
const handleContainerClick = (e, state2, container) => {
const target = e.target;
if (target.closest(".sui-select-badge svg")) {
deselectMultiOption(
state2,
target.closest(".sui-select-badge")?.getAttribute("data-value"),
container
);
handleBadgeOverflow(state2, container);
}
if (target.closest(".sui-select-button")) {
const container2 = target.closest(".sui-select-label");
if (container2.dropdown?.classList.contains("active")) {
closeDropdown(container2);
} else {
openDropdown(state2, container2);
}
}
if (target.closest(".sui-select-dropdown.active")) {
handleOptionSelect(target, state2, container);
}
};
const handleSelectKeyDown = (e, state2, container) => {
const active = !!container.dropdown?.classList.contains("active");
const focusedElement = document.activeElement;
if (e.key === "Tab" || e.key === "Escape") {
closeDropdown(container);
return;
}
if ((e.key === "Enter" || e.key === " ") && focusedElement?.tagName.toLowerCase() === "svg") {
const badgeElement = focusedElement?.closest(".sui-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");
deselectMultiOption(state2, badgeValue, container);
handleBadgeOverflow(state2, container);
setTimeout(() => {
if (nextBadgeValue) {
const badgeToFocus = container?.querySelector(
`.sui-select-badge[data-value="${nextBadgeValue}"] svg`
);
if (badgeToFocus) {
badgeToFocus.focus();
}
} else {
container?.button?.focus();
}
}, 0);
e.preventDefault();
e.stopImmediatePropagation();
return;
}
}
if ((e.key === " " || e.key === "Enter") && !active) {
openDropdown(state2, container);
e.preventDefault();
e.stopImmediatePropagation();
return;
}
if (e.key === "Enter" && active) {
const currentlyFocused = container?.querySelector(".sui-select-option.focused");
if (currentlyFocused) {
currentlyFocused.click();
e.preventDefault();
e.stopImmediatePropagation();
}
return;
}
e.preventDefault();
e.stopImmediatePropagation();
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-select-option") || []
).indexOf(interactiveOptions[currentInteractiveIndex - 1]);
recomputeOptions(state2, container);
}
if (e.key === "ArrowDown" && currentInteractiveIndex < interactiveOptions.length - 1) {
state2.focusIndex = Array.from(
container?.dropdown?.querySelectorAll(".sui-select-option") || []
).indexOf(interactiveOptions[currentInteractiveIndex + 1]);
recomputeOptions(state2, container);
}
if (e.key === "PageUp") {
state2.focusIndex = Array.from(
container?.dropdown?.querySelectorAll(".sui-select-option") || []
).indexOf(interactiveOptions[0]);
recomputeOptions(state2, container);
}
if (e.key === "PageDown") {
state2.focusIndex = Array.from(
container?.dropdown?.querySelectorAll(".sui-select-option") || []
).indexOf(interactiveOptions[interactiveOptions.length - 1]);
recomputeOptions(state2, container);
}
};
const state = {
optionsMap: {},
isMultipleMap: {},
focusIndex: -1,
placeholder: ""
};
const selects = document.querySelectorAll(".sui-select-label");
document.addEventListener("click", ({ target }) => {
for (const container of selects) {
if (!container.dropdown?.classList.contains("active") || !target)
continue;
if (!container.contains(target) && isVisible(container)) {
closeDropdown(container);
}
}
});
for (const container of selects) {
if (container.dataset.initialized === "true") continue;
const id = container.dataset.id;
const specialContainer = Object.assign(container, {
button: container.querySelector("button"),
dropdown: container.querySelector(".sui-select-dropdown"),
select: container.querySelector("select")
});
state.placeholder = specialContainer.button?.querySelector(".sui-select-value-span")?.innerText ?? "";
state.optionsMap[id] = JSON.parse(container.dataset.options);
state.isMultipleMap[id] = container.dataset.multiple === "true";
specialContainer.addEventListener(
"click",
(e) => handleContainerClick(e, state, specialContainer)
);
specialContainer.addEventListener(
"keydown",
(e) => handleSelectKeyDown(e, state, specialContainer)
);
if (state.isMultipleMap[id]) {
observeResize(specialContainer.button, () => {
handleBadgeOverflow(state, specialContainer);
});
handleBadgeOverflow(state, specialContainer);
}
container.dataset.initialized = "true";
}
}
document.addEventListener("astro:page-load", loadSelects);