// --- Class Name Constants for MultiSelectSimple --- const MSS_COMPONENT_WRAPPER_CLASS = "mss-component-wrapper"; const MSS_SELECTED_ITEMS_CONTAINER_CLASS = "mss-selected-items-container"; const MSS_SELECTED_ITEM_PILL_CLASS = "mss-selected-item-pill"; const MSS_SELECTED_ITEM_TEXT_CLASS = "mss-selected-item-text"; const MSS_SELECTED_ITEM_DELETE_BTN_CLASS = "mss-selected-item-delete-btn"; const MSS_INPUT_CONTROLS_CONTAINER_CLASS = "mss-input-controls-container"; // New container for input and create button const MSS_INPUT_WRAPPER_CLASS = "mss-input-wrapper"; const MSS_TEXT_INPUT_CLASS = "mss-text-input"; const MSS_CREATE_NEW_BUTTON_CLASS = "mss-create-new-button"; const MSS_OPTIONS_LIST_CLASS = "mss-options-list"; const MSS_OPTION_ITEM_CLASS = "mss-option-item"; const MSS_OPTION_ITEM_NAME_CLASS = "mss-option-item-name"; const MSS_OPTION_ITEM_DETAIL_CLASS = "mss-option-item-detail"; const MSS_HIGHLIGHTED_OPTION_CLASS = "mss-option-item-highlighted"; const MSS_HIDDEN_SELECT_CLASS = "mss-hidden-select"; // State classes for MultiSelectSimple const MSS_STATE_NO_SELECTION = "mss-state-no-selection"; const MSS_STATE_HAS_SELECTION = "mss-state-has-selection"; const MSS_STATE_LIST_OPEN = "mss-state-list-open"; // --- MultiSelectSimple Element --- export class MultiSelectSimple extends HTMLElement { static formAssociated = true; constructor() { super(); this.internals_ = this.attachInternals(); this._value = []; // Array of selected item IDs this._options = [ // Default options { id: "opt1", name: "Option Alpha", additional_data: "Info A" }, { id: "opt2", name: "Option Beta", additional_data: "Info B" }, { id: "opt3", name: "Option Gamma", additional_data: "Info C" }, { id: "opt4", name: "Option Delta", additional_data: "Info D" }, ]; this._filteredOptions = []; this._highlightedIndex = -1; this._isOptionsListVisible = false; this._setupTemplates(); this._bindEventHandlers(); } _setupTemplates() { this.optionTemplate = document.createElement("template"); this.optionTemplate.innerHTML = `
  • `; this.selectedItemTemplate = document.createElement("template"); this.selectedItemTemplate.innerHTML = ` `; } _bindEventHandlers() { this._handleInput = this._handleInput.bind(this); this._handleKeyDown = this._handleKeyDown.bind(this); this._handleFocus = this._handleFocus.bind(this); this._handleBlur = this._handleBlur.bind(this); this._handleOptionMouseDown = this._handleOptionMouseDown.bind(this); this._handleOptionClick = this._handleOptionClick.bind(this); this._handleCreateNewButtonClick = this._handleCreateNewButtonClick.bind(this); } _getItemById(id) { return this._options.find((opt) => opt.id === id); } // --- Public Methods --- setOptions(newOptions) { if ( Array.isArray(newOptions) && newOptions.every((o) => o && typeof o.id === "string" && typeof o.name === "string") ) { this._options = [...newOptions]; const validValues = this._value.filter((id) => this._getItemById(id)); if (validValues.length !== this._value.length) { this.value = validValues; } this._filteredOptions = []; this._highlightedIndex = -1; if (this.inputElement && this.inputElement.value) { this._handleInput({ target: this.inputElement }); } else { this._hideOptionsList(); } } else { console.error("setOptions expects an array of objects with id and name properties."); } } get value() { return this._value; } set value(newVal) { if (Array.isArray(newVal)) { this._value = [ ...new Set(newVal.filter((id) => typeof id === "string" && this._getItemById(id))), ]; } else { this._value = []; } this._updateFormValue(); if (this.selectedItemsContainer) this._renderSelectedItems(); this._updateRootElementStateClasses(); } get name() { return this.getAttribute("name"); } set name(value) { this.setAttribute("name", value); if (this.hiddenSelect) this.hiddenSelect.name = value; } connectedCallback() { this._render(); this.inputControlsContainer = this.querySelector(`.${MSS_INPUT_CONTROLS_CONTAINER_CLASS}`); this.inputWrapper = this.querySelector(`.${MSS_INPUT_WRAPPER_CLASS}`); this.inputElement = this.querySelector(`.${MSS_TEXT_INPUT_CLASS}`); this.createNewButton = this.querySelector(`.${MSS_CREATE_NEW_BUTTON_CLASS}`); this.optionsListElement = this.querySelector(`.${MSS_OPTIONS_LIST_CLASS}`); this.selectedItemsContainer = this.querySelector(`.${MSS_SELECTED_ITEMS_CONTAINER_CLASS}`); this.hiddenSelect = this.querySelector(`.${MSS_HIDDEN_SELECT_CLASS}`); if (this.name && this.hiddenSelect) this.hiddenSelect.name = this.name; this.inputElement.addEventListener("input", this._handleInput); this.inputElement.addEventListener("keydown", this._handleKeyDown); this.inputElement.addEventListener("focus", this._handleFocus); this.inputElement.addEventListener("blur", this._handleBlur); this.optionsListElement.addEventListener("mousedown", this._handleOptionMouseDown); this.optionsListElement.addEventListener("click", this._handleOptionClick); this.createNewButton.addEventListener("click", this._handleCreateNewButtonClick); this._updateRootElementStateClasses(); // Create button is always visible now, visibility logic removed from _handleInput // this.createNewButton.classList.add('hidden'); // No longer initially hidden by default in JS if (this.hasAttribute("value")) { try { this.value = JSON.parse(this.getAttribute("value")); } catch (e) { this.value = this.getAttribute("value") .split(",") .map((s) => s.trim()) .filter(Boolean); } } else { this._renderSelectedItems(); this._synchronizeHiddenSelect(); } if (this.hasAttribute("disabled")) this.disabledCallback(true); } disconnectedCallback() { if (this.inputElement) { this.inputElement.removeEventListener("input", this._handleInput); this.inputElement.removeEventListener("keydown", this._handleKeyDown); this.inputElement.removeEventListener("focus", this._handleFocus); this.inputElement.removeEventListener("blur", this._handleBlur); } if (this.optionsListElement) { this.optionsListElement.removeEventListener("mousedown", this._handleOptionMouseDown); this.optionsListElement.removeEventListener("click", this._handleOptionClick); } if (this.createNewButton) { this.createNewButton.removeEventListener("click", this._handleCreateNewButtonClick); } clearTimeout(this._blurTimeout); } formAssociatedCallback(form) {} formDisabledCallback(disabled) { this.disabledCallback(disabled); } formResetCallback() { this.value = []; this._hideOptionsList(); if (this.inputElement) this.inputElement.value = ""; // Create button visibility is not tied to reset, it's always shown this._updateRootElementStateClasses(); } formStateRestoreCallback(state, mode) { this.value = Array.isArray(state) ? state : []; this._updateRootElementStateClasses(); } _synchronizeHiddenSelect() { if (!this.hiddenSelect) return; this.hiddenSelect.innerHTML = ""; this._value.forEach((id) => { const option = document.createElement("option"); option.value = id; option.textContent = id; option.selected = true; this.hiddenSelect.appendChild(option); }); } _updateFormValue() { this.internals_.setFormValue(null); this._synchronizeHiddenSelect(); } disabledCallback(disabled) { if (this.inputElement) this.inputElement.disabled = disabled; if (this.createNewButton) this.createNewButton.disabled = disabled; this.classList.toggle("opacity-50", disabled); this.classList.toggle("cursor-not-allowed", disabled); if (this.selectedItemsContainer) this.selectedItemsContainer.classList.toggle("pointer-events-none", disabled); this.querySelectorAll(`.${MSS_SELECTED_ITEM_DELETE_BTN_CLASS}`).forEach( (btn) => (btn.disabled = disabled), ); if (this.hiddenSelect) this.hiddenSelect.disabled = disabled; } _updateRootElementStateClasses() { this.classList.toggle(MSS_STATE_NO_SELECTION, this._value.length === 0); this.classList.toggle(MSS_STATE_HAS_SELECTION, this._value.length > 0); this.classList.toggle(MSS_STATE_LIST_OPEN, this._isOptionsListVisible); } _render() { const componentId = this.id || `mss-${crypto.randomUUID().slice(0, 8)}`; if (!this.id) this.setAttribute("id", componentId); this.innerHTML = `
    `; } _createSelectedItemElement(itemId) { const itemData = this._getItemById(itemId); if (!itemData) return null; const fragment = this.selectedItemTemplate.content.cloneNode(true); const pillEl = fragment.firstElementChild; const textEl = pillEl.querySelector('[data-ref="textEl"]'); textEl.textContent = itemData.name; if (itemData.additional_data) { const detailSpan = document.createElement("span"); detailSpan.className = "ml-1 opacity-75 text-xs"; detailSpan.textContent = `(${itemData.additional_data})`; textEl.appendChild(detailSpan); } const deleteBtn = pillEl.querySelector('[data-ref="deleteBtn"]'); deleteBtn.setAttribute("aria-label", `Remove ${itemData.name}`); deleteBtn.dataset.id = itemId; deleteBtn.disabled = this.hasAttribute("disabled"); deleteBtn.addEventListener("click", (e) => { e.stopPropagation(); this._handleDeleteSelectedItem(itemId); }); return pillEl; } _renderSelectedItems() { if (!this.selectedItemsContainer) return; this.selectedItemsContainer.innerHTML = ""; this._value.forEach((id) => { const pillEl = this._createSelectedItemElement(id); if (pillEl) this.selectedItemsContainer.appendChild(pillEl); }); if (this._value.length === 0) { this.selectedItemsContainer.innerHTML = `No items selected`; } this._updateRootElementStateClasses(); } _createOptionElement(itemData, index) { const fragment = this.optionTemplate.content.cloneNode(true); const li = fragment.firstElementChild; const nameEl = li.querySelector('[data-ref="nameEl"]'); const detailEl = li.querySelector('[data-ref="detailEl"]'); nameEl.textContent = itemData.name; detailEl.textContent = itemData.additional_data ? `(${itemData.additional_data})` : ""; li.dataset.id = itemData.id; li.setAttribute("aria-selected", String(index === this._highlightedIndex)); if (index === this._highlightedIndex) { li.classList.add(MSS_HIGHLIGHTED_OPTION_CLASS); li.id = `highlighted-option-${this.id || "multi-select-simple"}`; } return li; } _renderOptionsList() { if (!this.optionsListElement) return; this.optionsListElement.innerHTML = ""; if (this._filteredOptions.length === 0 || !this._isOptionsListVisible) { this.optionsListElement.classList.add("hidden"); } else { this.optionsListElement.classList.remove("hidden"); this._filteredOptions.forEach((item, index) => { const optionEl = this._createOptionElement(item, index); this.optionsListElement.appendChild(optionEl); }); const highlightedElement = this.optionsListElement.querySelector( `#highlighted-option-${this.id || "multi-select-simple"}`, ); if (highlightedElement) highlightedElement.scrollIntoView({ block: "nearest" }); } this._updateRootElementStateClasses(); } _handleCreateNewButtonClick() { const inputValue = this.inputElement ? this.inputElement.value.trim() : ""; console.log(`"Create New" button clicked. Current input value: "${inputValue}"`); // User will implement the actual creation logic here. } _handleInput(event) { const searchTerm = event.target.value; // "Create New" button is always visible, no need to toggle based on input here. if (searchTerm.length === 0) { this._filteredOptions = []; this._isOptionsListVisible = false; } else { const searchTermLower = searchTerm.toLowerCase(); this._filteredOptions = this._options.filter((item) => { if (this._value.includes(item.id)) return false; return ( item.name.toLowerCase().includes(searchTermLower) || (item.additional_data && item.additional_data.toLowerCase().includes(searchTermLower)) ); }); this._isOptionsListVisible = this._filteredOptions.length > 0; } this._highlightedIndex = this._filteredOptions.length > 0 ? 0 : -1; this._renderOptionsList(); } _handleKeyDown(event) { if (this.inputElement.disabled) return; if (event.key === "Backspace") { return; } if (!this._isOptionsListVisible || this._filteredOptions.length === 0) { if (event.key === "Escape") this._hideOptionsList(); return; } switch (event.key) { case "ArrowDown": event.preventDefault(); this._highlightedIndex = (this._highlightedIndex + 1) % this._filteredOptions.length; this._renderOptionsList(); break; case "ArrowUp": event.preventDefault(); this._highlightedIndex = (this._highlightedIndex - 1 + this._filteredOptions.length) % this._filteredOptions.length; this._renderOptionsList(); break; case "Enter": event.preventDefault(); if (this._highlightedIndex > -1 && this._filteredOptions[this._highlightedIndex]) { this._selectItem(this._filteredOptions[this._highlightedIndex].id); } break; case "Escape": event.preventDefault(); this._hideOptionsList(); break; case "Tab": this._hideOptionsList(); break; } } _hideOptionsList() { this._isOptionsListVisible = false; this._highlightedIndex = -1; if (this.optionsListElement) this._renderOptionsList(); } _handleFocus() { if (this.inputElement.disabled) return; if (this.inputWrapper) this.inputWrapper.classList.add( "border", "border-gray-300", "focus-within:border-blue-500", "focus-within:ring-1", "focus-within:ring-blue-500", ); if (this.inputElement.value.length > 0) { this._handleInput({ target: this.inputElement }); } else { this._hideOptionsList(); } this._updateRootElementStateClasses(); } _blurTimeout = null; _handleBlur() { if (this.inputWrapper) this.inputWrapper.classList.remove( "border", "border-gray-300", "focus-within:border-blue-500", "focus-within:ring-1", "focus-within:ring-blue-500", ); this._blurTimeout = setTimeout(() => { if ( !this.contains(document.activeElement) || document.activeElement === this.createNewButton ) { if ( document.activeElement !== this.createNewButton && !(this.optionsListElement && this.optionsListElement.contains(document.activeElement)) ) { this._hideOptionsList(); } } }, 150); } _handleOptionMouseDown(event) { event.preventDefault(); } _handleOptionClick(event) { const li = event.target.closest("li[data-id]"); if (li) { const itemId = li.dataset.id; this._selectItem(itemId); } } _selectItem(itemId) { if (itemId && !this._value.includes(itemId)) { this._value.push(itemId); this._updateFormValue(); this._renderSelectedItems(); } if (this.inputElement) this.inputElement.value = ""; this._filteredOptions = []; this._hideOptionsList(); // Create button remains visible if (this.inputElement) this.inputElement.focus(); } _handleDeleteSelectedItem(itemId) { this._value = this._value.filter((id) => id !== itemId); this._updateFormValue(); this._renderSelectedItems(); if (this.inputElement) { this.inputElement.focus(); this._handleInput({ target: this.inputElement }); } } } customElements.define("multi-select-simple", MultiSelectSimple); // --- Demo Page Script for MultiSelectSimple --- const myFormSimple = document.getElementById("myFormSimple"); const formDataDisplaySimple = document.getElementById("form-data-display-simple"); const formDataPreSimple = formDataDisplaySimple.querySelector("pre"); myFormSimple.addEventListener("submit", function (event) { event.preventDefault(); const formData = new FormData(this); let output = ""; console.log("Simple Form Submitted. FormData entries:"); for (let [name, value] of formData.entries()) { console.log(`FormData - ${name}: ${value}`); output += `FormData - ${name}: ${value}\n`; } const simpleItemSelectEl = document.getElementById("simple-item-select"); output += `\nCustom element .value (IDs): [${simpleItemSelectEl.value.join(", ")}]`; formDataPreSimple.textContent = output; formDataDisplaySimple.classList.remove("hidden"); }); myFormSimple.addEventListener("reset", function (event) { console.log("Simple Form reset!"); formDataPreSimple.textContent = ""; formDataDisplaySimple.classList.add("hidden"); setTimeout(() => { const el = document.getElementById("simple-item-select"); if (el) { console.log(`simple-item-select .value after reset:`, el.value); if (el.hiddenSelect) { const hiddenOptions = Array.from(el.hiddenSelect.options).map((opt) => opt.value); console.log(`simple-item-select hidden select values:`, hiddenOptions); } } }, 0); });