@studiocms/ui
Version:
The UI library for StudioCMS. Includes the layouts & components we use to build StudioCMS.
341 lines (340 loc) • 13 kB
JavaScript
class SUISelectElement extends HTMLElement {
CONSTANTS = {
OPTION_HEIGHT: 36,
BORDER_SIZE: 2,
MARGIN: 4,
BADGE_PADDING: 80
};
observerMap = /* @__PURE__ */ new WeakMap();
state = {
options: [],
isMultiple: false,
focusIndex: -1,
placeholder: ""
};
button;
dropdown;
select;
// biome-ignore lint/complexity/noUselessConstructor: custom element requirement
constructor() {
super();
}
connectedCallback() {
this.button = this.querySelector(".sui-select-button");
this.dropdown = this.querySelector(".sui-select-dropdown");
this.select = this.querySelector("select");
document.addEventListener("click", ({ target }) => {
if (this.dropdown?.classList.contains("active") || !target) {
return;
}
if (!this.contains(target) && this.isVisible(this)) {
this.closeDropdown();
}
});
this.state.placeholder = this.button?.querySelector(".sui-select-value-span")?.innerText ?? "";
this.state.options = JSON.parse(this.dataset.options);
this.state.isMultiple = this.dataset.multiple === "true";
this.addEventListener("click", (e) => this.handleContainerClick(e));
this.addEventListener("keydown", (e) => this.handleSelectKeyDown(e));
if (this.state.isMultiple) {
this.observeResize(this.button, () => {
this.handleBadgeOverflow();
});
this.handleBadgeOverflow();
}
}
observeResize = (element, callback) => {
if (this.observerMap.has(element)) {
this.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);
this.observerMap.set(element, { observer, callback });
return () => this.unobserveResize(element);
};
unobserveResize = (element) => {
const data = this.observerMap.get(element);
if (data) {
data.observer.disconnect();
this.observerMap.delete(element);
}
};
isVisible = (elem) => elem.offsetWidth > 0 || elem.offsetHeight > 0 || elem.getClientRects().length > 0;
getDropdownPosition = (element) => {
const rect = element.getBoundingClientRect();
const optionsCount = this.state.options?.length ?? 0;
const { OPTION_HEIGHT, BORDER_SIZE, MARGIN } = this.CONSTANTS;
const dropdownHeight = optionsCount * OPTION_HEIGHT + BORDER_SIZE + MARGIN;
const customRect = {
top: rect.bottom + MARGIN,
bottom: rect.bottom + MARGIN + dropdownHeight,
left: rect.left,
right: rect.right,
width: rect.width,
x: rect.x,
y: rect.y + rect.height + this.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
};
};
closeDropdown = () => {
if (!this?.button || !this?.dropdown) return;
this.dropdown.classList.remove("active", "above");
this.button.ariaExpanded = "false";
};
openDropdown = () => {
if (!this.button || !this.dropdown) return;
const { isAbove } = this.getDropdownPosition(this.button);
this.button.ariaExpanded = "true";
this.dropdown.classList.add("active", ...isAbove ? [] : ["above"]);
};
createSelectBadge = (value, label) => {
const badge = document.createElement("span");
badge.classList.add("sui-badge", "primary", "sm", "outlined", "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;
};
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) => this.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 };
};
handleBadgeOverflow = () => {
const buttonContainer = this.button?.parentElement?.parentElement;
const buttonValueSpan = this.button?.querySelector(".sui-select-value-span");
const activeSelects = this.dropdown?.querySelectorAll(".sui-select-option.selected");
const overflowContainer = this.querySelector(".sui-select-badge-container-below");
if (!buttonContainer || !overflowContainer || !this.button || !activeSelects) return;
const parentContainer = buttonContainer.parentElement;
if (!parentContainer) return;
overflowContainer.innerHTML = "";
buttonValueSpan.innerHTML = "";
if (activeSelects.length === 0) {
buttonValueSpan.innerText = this.state.placeholder;
return;
}
const { totalWidth, badges, tempContainer } = this.measureBadgesWidth(activeSelects);
const parentStyles = window.getComputedStyle(parentContainer);
const availableWidth = parentContainer.clientWidth - (Number.parseFloat(parentStyles.paddingLeft) || 0) - (Number.parseFloat(parentStyles.paddingRight) || 0);
const effectiveAvailableWidth = availableWidth - this.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);
}
};
updateLabel = () => {
if (this.state.isMultiple) {
this.handleBadgeOverflow();
} else {
const selected = this.querySelector(".sui-select-option.selected");
const selectedButtonSpan = this.button?.querySelector(
".sui-select-value-span"
);
if (selected && selectedButtonSpan) {
selectedButtonSpan.innerText = selected.innerText.trim();
}
}
};
deselectMultiOption = (id) => {
const selectOpt = this.dropdown?.querySelector(
`.sui-select-option[value='${id}']`
);
const max = Number.parseInt(this.dataset.multipleMax, 10);
const selectedCount = this.querySelectorAll(".sui-select-option.selected").length ?? 0;
const selectedCountEl = this.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 = this.select?.querySelector(
`option[value='${selectOpt.getAttribute("value")}']`
);
if (selectOptEl) {
selectOptEl.selected = !selectOpt.selected;
}
if (selectedCountEl) {
selectedCountEl.innerText = String(selectedCount + (isSelected ? -1 : 1));
}
this.updateLabel();
}
};
recomputeOptions = () => {
const optionElements = this.dropdown?.querySelectorAll(
".sui-select-option"
);
for (const entry of optionElements) {
if (Number.parseInt(entry.dataset.optionIndex, 10) === this.state.focusIndex) {
entry.classList.add("focused");
} else {
entry.classList.remove("focused");
}
}
};
getInteractiveOptions = () => {
const allOptions = this.dropdown?.querySelectorAll(
".sui-select-option"
);
return Array.from(allOptions).filter(
(option) => !option.classList.contains("hidden") && !option.classList.contains("disabled") && !option.hasAttribute("disabled")
);
};
handleOptionSelect = (target) => {
const option = target.closest(".sui-select-option");
const lastActive = this.dropdown?.querySelector(".sui-select-option.selected");
const isMultiple = this.state.isMultiple;
if (isMultiple) {
this.deselectMultiOption(option?.getAttribute("value"));
} else {
if (lastActive) {
lastActive.classList.remove("selected");
}
if (option) {
option.classList.add("selected");
if (this.select) {
this.select.value = option.getAttribute("value");
}
this.updateLabel();
}
this.closeDropdown();
}
};
handleContainerClick = (e) => {
const target = e.target;
if (target.closest(".sui-select-badge svg")) {
this.deselectMultiOption(
target.closest(".sui-select-badge")?.getAttribute("data-value")
);
this.handleBadgeOverflow();
}
if (target.closest(".sui-select-button")) {
const container = target.closest(".sui-select-label");
if (container.dropdown?.classList.contains("active")) {
this.closeDropdown();
} else {
this.openDropdown();
}
}
if (target.closest(".sui-select-dropdown.active")) {
this.handleOptionSelect(target);
}
};
handleSelectKeyDown = (e) => {
const active = !!this.dropdown?.classList.contains("active");
const focusedElement = document.activeElement;
if (e.key === "Tab" || e.key === "Escape") {
this.closeDropdown();
return;
}
if ((e.key === "Enter" || e.key === " ") && focusedElement?.tagName.toLowerCase() === "svg") {
const badgeElement = focusedElement?.closest(".sui-select-badge");
if (badgeElement && this.state.isMultiple) {
const badgeValue = badgeElement.getAttribute("data-value");
let nextBadge = badgeElement.previousElementSibling;
if (!nextBadge) {
nextBadge = badgeElement.nextElementSibling;
}
const nextBadgeValue = nextBadge?.getAttribute("data-value");
this.deselectMultiOption(badgeValue);
this.handleBadgeOverflow();
setTimeout(() => {
if (nextBadgeValue) {
const badgeToFocus = this.querySelector(
`.sui-select-badge[data-value="${nextBadgeValue}"] svg`
);
if (badgeToFocus) {
badgeToFocus.focus();
}
} else {
this.button?.focus();
}
}, 0);
e.preventDefault();
e.stopImmediatePropagation();
return;
}
}
if ((e.key === " " || e.key === "Enter") && !active) {
this.openDropdown();
e.preventDefault();
e.stopImmediatePropagation();
return;
}
if (e.key === "Enter" && active) {
const currentlyFocused = this.querySelector(".sui-select-option.focused");
if (currentlyFocused) {
currentlyFocused.click();
e.preventDefault();
e.stopImmediatePropagation();
}
return;
}
e.preventDefault();
e.stopImmediatePropagation();
const interactiveOptions = this.getInteractiveOptions();
const currentInteractiveIndex = interactiveOptions.findIndex(
(option) => option.classList.contains("focused")
);
if (e.key === "ArrowUp" && currentInteractiveIndex > 0) {
this.state.focusIndex = Array.from(
this.dropdown?.querySelectorAll(".sui-select-option") || []
).indexOf(interactiveOptions[currentInteractiveIndex - 1]);
this.recomputeOptions();
}
if (e.key === "ArrowDown" && currentInteractiveIndex < interactiveOptions.length - 1) {
this.state.focusIndex = Array.from(
this.dropdown?.querySelectorAll(".sui-select-option") || []
).indexOf(interactiveOptions[currentInteractiveIndex + 1]);
this.recomputeOptions();
}
if (e.key === "PageUp") {
this.state.focusIndex = Array.from(
this.dropdown?.querySelectorAll(".sui-select-option") || []
).indexOf(interactiveOptions[0]);
this.recomputeOptions();
}
if (e.key === "PageDown") {
this.state.focusIndex = Array.from(
this.dropdown?.querySelectorAll(".sui-select-option") || []
).indexOf(interactiveOptions[interactiveOptions.length - 1]);
this.recomputeOptions();
}
};
}
customElements.define("sui-select", SUISelectElement);
export {
SUISelectElement
};