UNPKG

@studiocms/ui

Version:

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

350 lines (349 loc) 14 kB
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);