UNPKG

@studiocms/ui

Version:

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

344 lines (343 loc) 12.9 kB
import { SUISelectElement } from "studiocms:ui/components/select/script"; class SUIComboboxElement extends SUISelectElement { input; state = { options: [], isMultiple: false, focusIndex: -1, placeholder: "", selectedOptions: [], isSelectingOption: false }; // biome-ignore lint/complexity/noUselessConstructor: custom element requirement constructor() { super(); } connectedCallback() { this.input = this.querySelector("input"); this.dropdown = this.querySelector(".sui-search-select-dropdown-list"); const selectedOptions = Array.from( this.dropdown?.querySelectorAll(".sui-search-select-option.selected") ?? [] ); this.state.placeholder = this.input?.placeholder ?? ""; this.state.options = JSON.parse(this.dataset.options ?? "{}"); this.state.isMultiple = this.dataset.multiple === "true"; this.state.selectedOptions = selectedOptions.map((x) => x.getAttribute("data-value") ?? ""); this.input?.addEventListener("focusin", () => this.handleContainerFocusIn()); this.addEventListener("focusout", () => this.handleContainerFocusOut()); this.addEventListener("keydown", (e) => this.handleSelectKeyDown(e)); this.input?.addEventListener("keyup", (e) => this.handleInputKeyup(e)); this.addEventListener("mousedown", (e) => this.handleContainerMouseDown(e)); if (this.state.isMultiple) { this.recalculateBadges(); } } createSearchSelectBadge = (value, label) => { const badge = this.createSelectBadge(value, label); badge.classList.remove("sui-select-badge"); badge.classList.add("sui-search-select-badge"); return badge; }; recalculateBadges = () => { const badgeContainer = this.querySelector(".sui-search-select-badge-container"); if (!badgeContainer || !this.input) return; badgeContainer.innerHTML = ""; const selectedValues = this.state.selectedOptions; const allOptions = this.state.options; if (selectedValues.length === 0) { this.input.placeholder = this.state.placeholder ?? ""; return; } for (const value of selectedValues.sort((a, b) => { const numA = Number.parseInt(a.match(/\d+/)?.[0] || "0", 10); const numB = Number.parseInt(b.match(/\d+/)?.[0] || "0", 10); return numA - numB; })) { const option = allOptions.find((opt) => opt.value === value); if (option) { const newBadge = this.createSearchSelectBadge(value, option.label); badgeContainer.appendChild(newBadge); } } }; updateLabel = () => { const selectedInput = this.input; if (this.state.isMultiple) { this.recalculateBadges(); if (selectedInput) { selectedInput.placeholder = this.state.placeholder; } } else { const selected = this.querySelector(".sui-search-select-option.selected"); if (selected && selectedInput) { selectedInput.placeholder = selected.innerText.trim(); } } }; updateOptionSelection = (value, forceState) => { const currentSelected = this.state.selectedOptions; const isCurrentlySelected = currentSelected.includes(value); const max = Number.parseInt(this.dataset.multipleMax, 10); if (!isCurrentlySelected && !Number.isNaN(max) && currentSelected.length >= max) { return false; } const newSelected = isCurrentlySelected ? currentSelected.filter((v) => v !== value) : [...currentSelected, value]; this.state.selectedOptions = newSelected; const option = this.dropdown?.querySelector( `.sui-search-select-option[data-value='${value}']` ); if (option) { option.classList.toggle("selected", forceState ?? !isCurrentlySelected); if (this.select) { this.select.value = option.getAttribute("value"); } } const selectedCountEl = this.querySelector( ".sui-search-select-max-span .sui-search-select-select-count" ); if (selectedCountEl) { selectedCountEl.innerText = String(newSelected.length); } return true; }; toggleMultiOption = (id) => { const success = this.updateOptionSelection(id); if (success) { this.recalculateBadges(); } }; recomputeOptions = () => { const optionElements = this.dropdown?.querySelectorAll( ".sui-search-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"); } } }; reconstructOptions = (filteredOptions) => { this.dropdown.innerHTML = ""; const selectedValues = this.state.selectedOptions; if (filteredOptions.length === 0) { this.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; this.dropdown?.appendChild(element); i++; } }; getInteractiveOptions = () => { const allOptions = this.dropdown?.querySelectorAll( ".sui-search-select-option" ); return Array.from(allOptions).filter( (option) => !option.classList.contains("hidden") && !option.classList.contains("disabled") && !option.hasAttribute("disabled") ); }; handleContainerMouseDown = (e) => { const target = e.target; if (!target.closest("input")) { e.preventDefault(); } if (this.input?.value.length === 0) { this.reconstructOptions(this.state.options); } if (target.closest(".sui-search-select-indicator")) { if (this.dropdown?.parentElement?.classList.contains("active")) { this.dropdown?.parentElement?.classList.remove("active", "above"); this.input?.blur(); this.input.value = ""; } else { this.dropdown?.parentElement?.classList.add("active"); this.input?.focus(); this.input.value = ""; } return; } if (target.closest(".sui-search-select-badge-container")) { this.dropdown?.parentElement?.classList.remove("active", "above"); this.input?.blur(); this.input.value = ""; } this.state.isSelectingOption = true; setTimeout(() => { this.state.isSelectingOption = false; }, 0); if (target.closest(".sui-search-select-badge svg")) { const value = target.closest(".sui-search-select-badge")?.getAttribute("data-value"); const success = this.updateOptionSelection(value); if (success) { this.recalculateBadges(); } return; } const opt = target.closest(".sui-search-select-option"); if (!opt?.dataset.value) return; if (opt.classList.contains("disabled") || opt.hasAttribute("disabled")) { this.input?.focus(); return; } const isMultiple = this.state.isMultiple; if (isMultiple) { const success = this.updateOptionSelection(opt.dataset.value); if (success) { this.updateLabel(); this.recalculateBadges(); } } else { const currentSelected = this.state.selectedOptions; for (const value of currentSelected) { this.updateOptionSelection(value, false); } this.updateOptionSelection(opt.dataset.value, true); this.updateLabel(); this.dropdown?.parentElement?.classList.remove("active", "above"); this.input?.blur(); this.input.value = ""; } }; handleSelectKeyDown = (e) => { const focusedElement = document.activeElement; if (e.key === "Escape" || e.key === "Tab") { this.input?.blur(); this.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 && 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.toggleMultiOption(badgeValue); this.recalculateBadges(); setTimeout(() => { if (nextBadgeValue) { const badgeToFocus = this.querySelector( `.sui-search-select-badge[data-value="${nextBadgeValue}"] svg` ); if (badgeToFocus) { badgeToFocus.focus(); } } }, 0); e.preventDefault(); e.stopImmediatePropagation(); return; } } 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-search-select-option") || [] ).indexOf(interactiveOptions[currentInteractiveIndex - 1]); this.recomputeOptions(); return; } if (e.key === "ArrowDown" && currentInteractiveIndex < interactiveOptions.length - 1) { this.state.focusIndex = Array.from( this.dropdown?.querySelectorAll(".sui-search-select-option") || [] ).indexOf(interactiveOptions[currentInteractiveIndex + 1]); this.recomputeOptions(); return; } if (e.key === "PageUp") { this.state.focusIndex = Array.from( this.dropdown?.querySelectorAll(".sui-search-select-option") || [] ).indexOf(interactiveOptions[0]); this.recomputeOptions(); return; } if (e.key === "PageDown") { this.state.focusIndex = Array.from( this.dropdown?.querySelectorAll(".sui-search-select-option") || [] ).indexOf(interactiveOptions[interactiveOptions.length - 1]); this.recomputeOptions(); return; } if (e.key === "Enter") { e.preventDefault(); e.stopImmediatePropagation(); const optionElements = this.dropdown?.querySelectorAll( ".sui-search-select-option" ); const focusedOption = Array.from(optionElements).find( (entry) => Number.parseInt(entry.dataset.optionIndex, 10) === this.state.focusIndex ); if (focusedOption && !focusedOption.classList.contains("disabled") && !focusedOption.hasAttribute("disabled")) { const value = focusedOption.dataset.value; if (!value) return; const isMultiple = this.state.isMultiple; if (isMultiple) { const success = this.updateOptionSelection(value); if (success) { this.updateLabel(); this.recalculateBadges(); } } else { const currentSelected = this.state.selectedOptions; for (const existingValue of currentSelected) { this.updateOptionSelection(existingValue, false); } this.updateOptionSelection(value, true); this.updateLabel(); this.dropdown?.parentElement?.classList.remove("active", "above"); this.input.value = ""; } } return; } }; handleInputKeyup = (e) => { if (["Enter", "ArrowUp", "ArrowDown"].includes(e.key)) return; const value = this.input.value.trim().toLowerCase(); const allOptions = this.state.options; if (value.length === 0) { this.reconstructOptions(allOptions); return; } const filteredOptions = allOptions.filter((option) => option.label.toLowerCase().includes(value)) ?? []; this.state.focusIndex = 0; this.reconstructOptions(filteredOptions); }; handleContainerFocusOut = () => { if (this.state.isSelectingOption) return; this.input.value = ""; this.reconstructOptions(this.state.options); this.dropdown?.parentElement?.classList.remove("active", "above"); }; handleContainerFocusIn = () => { const allDropdowns = document.querySelectorAll(".sui-search-select-dropdown-list"); for (const dropdown of allDropdowns) { if (dropdown !== this.dropdown) { dropdown.parentElement?.classList.remove("active", "above"); } } const { isAbove } = this.getDropdownPosition(this.input); this.dropdown?.parentElement?.classList.add("active", ...isAbove ? [] : ["above"]); }; } customElements.define("sui-combobox", SUIComboboxElement);