UNPKG

@studiocms/ui

Version:

The UI library for StudioCMS. Includes the layouts & components we use to build StudioCMS.

341 lines (340 loc) 13 kB
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 };