UNPKG

@studiocms/ui

Version:

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

390 lines (389 loc) 15.9 kB
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);