multi-selects

This commit is contained in:
Simon Martens
2025-05-29 20:01:46 +02:00
parent 3b9ec7d493
commit e4a07c62ab
5 changed files with 2075 additions and 215 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -10,6 +10,8 @@ import { TabList } from "./tab-list.js";
import { AbbreviationTooltips } from "./abbrev-tooltips.js"; import { AbbreviationTooltips } from "./abbrev-tooltips.js";
import { IntLink } from "./int-link.js"; import { IntLink } from "./int-link.js";
import { ImageReel } from "./image-reel.js"; import { ImageReel } from "./image-reel.js";
import { MultiSelectRole } from "./multi-select-role.js";
import { MultiSelectSimple } from "./multi-select-simple.js";
const FILTER_LIST_ELEMENT = "filter-list"; const FILTER_LIST_ELEMENT = "filter-list";
const SCROLL_BUTTON_ELEMENT = "scroll-button"; const SCROLL_BUTTON_ELEMENT = "scroll-button";
@@ -20,6 +22,8 @@ const POPUP_IMAGE_ELEMENT = "popup-image";
const TABLIST_ELEMENT = "tab-list"; const TABLIST_ELEMENT = "tab-list";
const FILTER_PILL_ELEMENT = "filter-pill"; const FILTER_PILL_ELEMENT = "filter-pill";
const IMAGE_REEL_ELEMENT = "image-reel"; const IMAGE_REEL_ELEMENT = "image-reel";
const MULTI_SELECT_ROLE_ELEMENT = "multi-select-places";
const MULTI_SELECT_SIMPLE_ELEMENT = "multi-select-simple";
customElements.define(INT_LINK_ELEMENT, IntLink); customElements.define(INT_LINK_ELEMENT, IntLink);
customElements.define(ABBREV_TOOLTIPS_ELEMENT, AbbreviationTooltips); customElements.define(ABBREV_TOOLTIPS_ELEMENT, AbbreviationTooltips);
@@ -30,5 +34,7 @@ customElements.define(POPUP_IMAGE_ELEMENT, PopupImage);
customElements.define(TABLIST_ELEMENT, TabList); customElements.define(TABLIST_ELEMENT, TabList);
customElements.define(FILTER_PILL_ELEMENT, FilterPill); customElements.define(FILTER_PILL_ELEMENT, FilterPill);
customElements.define(IMAGE_REEL_ELEMENT, ImageReel); customElements.define(IMAGE_REEL_ELEMENT, ImageReel);
customElements.define(MULTI_SELECT_ROLE_ELEMENT, MultiSelectRole);
customElements.define(MULTI_SELECT_SIMPLE_ELEMENT, MultiSelectSimple);
export { FilterList, ScrollButton, AbbreviationTooltips }; export { FilterList, ScrollButton, AbbreviationTooltips };

View File

@@ -0,0 +1,947 @@
// --- Class Name Constants for Styling ---
const MSR_COMPONENT_WRAPPER_CLASS = "msr-component-wrapper";
const MSR_SELECTED_ITEMS_CONTAINER_CLASS = "msr-selected-items-container";
const MSR_SELECTED_ITEM_PILL_CLASS = "msr-selected-item-pill";
const MSR_SELECTED_ITEM_TEXT_CLASS = "msr-selected-item-text";
const MSR_SELECTED_ITEM_DELETE_BTN_CLASS = "msr-selected-item-delete-btn";
const MSR_CONTROLS_AREA_CLASS = "msr-controls-area";
const MSR_INPUT_AREA_WRAPPER_CLASS = "msr-input-area-wrapper";
const MSR_INPUT_AREA_WRAPPER_DEFAULT_BORDER_CLASS = "msr-input-area-default-border";
const MSR_INPUT_AREA_WRAPPER_STAGED_CLASS = "msr-input-area-staged";
const MSR_STAGING_AREA_CONTAINER_CLASS = "msr-staging-area-container";
const MSR_STAGED_ITEM_PILL_CLASS = "msr-staged-item-pill";
const MSR_STAGED_ITEM_TEXT_CLASS = "msr-staged-item-text";
const MSR_STAGED_ROLE_SELECT_CLASS = "msr-staged-role-select";
const MSR_STAGED_CANCEL_BTN_CLASS = "msr-staged-cancel-btn";
const MSR_TEXT_INPUT_CLASS = "msr-text-input";
const MSR_ADD_BUTTON_CLASS = "msr-add-button";
const MSR_OPTIONS_LIST_CLASS = "msr-options-list";
const MSR_OPTION_ITEM_CLASS = "msr-option-item";
const MSR_OPTION_ITEM_NAME_CLASS = "msr-option-item-name";
const MSR_OPTION_ITEM_DETAIL_CLASS = "msr-option-item-detail";
const MSR_HIGHLIGHTED_OPTION_CLASS = "msr-option-item-highlighted";
const MSR_HIDDEN_SELECT_CLASS = "msr-hidden-select";
// --- State Classes for Root Element ---
const MSR_STATE_NO_SELECTION = "msr-state-no-selection";
const MSR_STATE_HAS_SELECTION = "msr-state-has-selection";
const MSR_STATE_LIST_OPEN = "msr-state-list-open";
const MSR_STATE_ITEM_STAGED = "msr-state-item-staged";
class MultiSelectRole extends HTMLElement {
// Renamed class
static formAssociated = true;
constructor() {
super();
this.internals_ = this.attachInternals();
this._value = [];
this._stagedItem = null;
// Default options, can be overridden by setOptions()
this._options = [
{ id: "item1", name: "First Item", additional_data: "Data A" },
{ id: "item2", name: "Second Item", additional_data: "Data B" },
{ id: "item3", name: "Third Item", additional_data: "Data C" },
];
// Default roles, can be overridden by setRoles()
this._roles = ["Primary", "Secondary", "Auxiliary"];
this._filteredOptions = [];
this._highlightedIndex = -1;
this._isOptionsListVisible = false;
this._setupTemplates();
this._bindEventHandlers();
}
_setupTemplates() {
this.optionTemplate = document.createElement("template");
this.optionTemplate.innerHTML = `
<li role="option" class="${MSR_OPTION_ITEM_CLASS} px-3 py-2 text-sm cursor-pointer transition-colors duration-75 group">
<span data-ref="nameEl" class="${MSR_OPTION_ITEM_NAME_CLASS} font-semibold"></span>
<span data-ref="detailEl" class="${MSR_OPTION_ITEM_DETAIL_CLASS} text-xs ml-2"></span>
</li>
`;
this.selectedItemTemplate = document.createElement("template");
this.selectedItemTemplate.innerHTML = `
<span class="${MSR_SELECTED_ITEM_PILL_CLASS} flex items-center">
<span data-ref="textEl" class="${MSR_SELECTED_ITEM_TEXT_CLASS}"></span>
<button type="button" data-ref="deleteBtn" class="${MSR_SELECTED_ITEM_DELETE_BTN_CLASS} ml-2">&times;</button>
</span>
`;
this.stagedPlacePillTemplate = document.createElement("template"); // Renaming to generic item
this.stagedPlacePillTemplate.innerHTML = `
<span class="${MSR_STAGED_ITEM_PILL_CLASS} flex items-center">
<span data-ref="nameEl" class="${MSR_STAGED_ITEM_TEXT_CLASS}"></span>
</span>
`;
this.stagedCancelBtnTemplate = document.createElement("template");
this.stagedCancelBtnTemplate.innerHTML = `
<button type="button" class="${MSR_STAGED_CANCEL_BTN_CLASS} flex items-center justify-center w-5 h-5">&times;</button>
`;
}
_bindEventHandlers() {
this._handleInput = this._handleInput.bind(this);
this._handleKeyDown = this._handleKeyDown.bind(this);
this._handleInputKeyDown = this._handleInputKeyDown.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._handleAddButtonClick = this._handleAddButtonClick.bind(this);
this._handleCancelStagedItem = this._handleCancelStagedItem.bind(this);
this._handleStagedRoleChange = this._handleStagedRoleChange.bind(this);
}
_getItemById(id) {
// Renamed from _getPlaceById
return this._options.find((opt) => opt.id === id);
}
_getAvailableRolesForItem(itemId) {
// Renamed from _getAvailableRolesForPlace
const assignedRoles = this._value
.filter((item) => item.itemId === itemId) // Updated property name
.map((item) => item.role);
return this._roles.filter((role) => !assignedRoles.includes(role));
}
// --- Public Methods for Programmatic Configuration ---
setRoles(newRoles) {
if (Array.isArray(newRoles) && newRoles.every((r) => typeof r === "string")) {
this._roles = [...newRoles];
console.log("Roles set to:", this._roles);
// If an item is staged, its role dropdown needs to be updated
if (this._stagedItem && this._stagedItem.item) {
const availableRoles = this._getAvailableRolesForItem(this._stagedItem.item.id);
if (!availableRoles.includes(this._stagedItem.currentRole)) {
this._stagedItem.currentRole = availableRoles.length > 0 ? "" : null; // Set to placeholder or null if no roles
}
this._renderStagedPillOrInput(); // Re-render to update dropdown
this._updateAddButtonState();
}
// Potentially re-validate/update existing selected items if their roles are no longer valid
const validValues = this._value.filter((item) => this._roles.includes(item.role));
if (validValues.length !== this._value.length) {
this.value = validValues.map((item) => `${item.itemId},${item.role}`);
}
} else {
console.error("setRoles expects an array of strings.");
}
}
setOptions(newOptions) {
if (
Array.isArray(newOptions) &&
newOptions.every((o) => o && typeof o.id === "string" && typeof o.name === "string")
) {
this._options = [...newOptions];
console.log("Options set to:", this._options);
// Re-validate current value against new options
const validValues = this._value.filter((valItem) => this._getItemById(valItem.itemId));
if (validValues.length !== this._value.length) {
this.value = validValues.map((item) => `${item.itemId},${item.role}`);
}
// Clear staged item if its item is no longer in options
if (
this._stagedItem &&
this._stagedItem.item &&
!this._getItemById(this._stagedItem.item.id)
) {
this._handleCancelStagedItem(); // Clear and reset
}
this._filteredOptions = [];
this._highlightedIndex = -1;
if (this.inputElement && this.inputElement.value) {
// Re-filter if there was a search term
this._handleInput({ target: this.inputElement });
} else {
this._hideOptionsList();
}
} else {
console.error("setOptions expects an array of objects with id and name properties.");
}
}
// Programmatic setting of selected items is done via the `value` setter.
// Example: element.value = ["itemId1,RoleA", "itemId2,RoleB"];
get value() {
return this._value;
}
set value(val) {
if (Array.isArray(val)) {
this._value = val
.map((itemStr) => {
if (typeof itemStr === "string") {
const parts = itemStr.split(",");
if (parts.length === 2) {
const item = this._getItemById(parts[0]); // Use generic _getItemById
const roleName = parts[1];
if (item && this._roles.includes(roleName)) {
const existing = this._value.find(
(v) => v.itemId === parts[0] && v.role === roleName,
);
if (existing) return null;
return { itemId: parts[0], role: roleName, instanceId: crypto.randomUUID() };
}
}
}
return null;
})
.filter((item) => item !== null);
} 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.inputAreaWrapper = this.querySelector(`.${MSR_INPUT_AREA_WRAPPER_CLASS}`);
this.inputElement = this.querySelector(`.${MSR_TEXT_INPUT_CLASS}`);
this.stagedItemPillContainer = this.querySelector(`.${MSR_STAGING_AREA_CONTAINER_CLASS}`);
this.optionsListElement = this.querySelector(`.${MSR_OPTIONS_LIST_CLASS}`);
this.selectedItemsContainer = this.querySelector(`.${MSR_SELECTED_ITEMS_CONTAINER_CLASS}`);
this.addButtonElement = this.querySelector(`.${MSR_ADD_BUTTON_CLASS}`);
this.hiddenSelect = this.querySelector(`.${MSR_HIDDEN_SELECT_CLASS}`);
if (this.name && this.hiddenSelect) {
this.hiddenSelect.name = this.name;
}
this.inputElement.addEventListener("input", this._handleInput);
this.inputElement.addEventListener("keydown", this._handleInputKeyDown);
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.addButtonElement.addEventListener("click", this._handleAddButtonClick);
this.addEventListener("keydown", this._handleKeyDown);
this._renderStagedPillOrInput();
this._updateAddButtonState();
this._updateRootElementStateClasses();
if (this.hasAttribute("value")) {
try {
const parsedValue = JSON.parse(this.getAttribute("value"));
this.value = parsedValue;
} catch (e) {
const attrValue = this.getAttribute("value");
if (attrValue.startsWith("[") && attrValue.endsWith("]")) {
console.warn(
"Failed to parse value attribute as JSON array, attempting manual parse:",
attrValue,
e,
);
try {
const items = attrValue
.slice(1, -1)
.split('","')
.map((s) => s.replace(/"/g, "").trim());
this.value = items;
} catch (e2) {
console.error("Manual parse of value attribute also failed:", attrValue, e2);
this.value = [];
}
} else {
this.value = [attrValue];
}
}
} 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._handleInputKeyDown);
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.addButtonElement) {
this.addButtonElement.removeEventListener("click", this._handleAddButtonClick);
}
this.removeEventListener("keydown", this._handleKeyDown);
clearTimeout(this._blurTimeout);
}
formAssociatedCallback(form) {}
formDisabledCallback(disabled) {
this.disabledCallback(disabled);
}
formResetCallback() {
this.value = [];
this._stagedItem = null;
this._renderStagedPillOrInput();
this._hideOptionsList();
this._updateAddButtonState();
if (this.inputElement) this.inputElement.value = "";
this._updateRootElementStateClasses();
}
formStateRestoreCallback(state, mode) {
if (Array.isArray(state) && state.every((s) => typeof s === "string" && s.includes(","))) {
this.value = state;
} else {
this.value = [];
}
this._updateRootElementStateClasses();
}
_synchronizeHiddenSelect() {
if (!this.hiddenSelect) return;
this.hiddenSelect.innerHTML = "";
this._value.forEach((item) => {
const option = document.createElement("option");
option.value = `${item.itemId},${item.role}`; // Updated to itemId
option.textContent = `${item.itemId} (${item.role})`;
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.addButtonElement) {
this.addButtonElement.classList.toggle(
"hidden",
disabled || !this._stagedItem || !this._stagedItem.currentRole,
);
this.addButtonElement.disabled =
disabled || !this._stagedItem || !this._stagedItem.currentRole;
}
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(`.${MSR_SELECTED_ITEM_DELETE_BTN_CLASS}`).forEach(
(btn) => (btn.disabled = disabled),
);
const stagedRoleSelect = this.querySelector(`.${MSR_STAGED_ROLE_SELECT_CLASS}`);
if (stagedRoleSelect) stagedRoleSelect.disabled = disabled;
if (this.hiddenSelect) this.hiddenSelect.disabled = disabled;
}
_updateRootElementStateClasses() {
this.classList.toggle(MSR_STATE_NO_SELECTION, this._value.length === 0);
this.classList.toggle(MSR_STATE_HAS_SELECTION, this._value.length > 0);
this.classList.toggle(MSR_STATE_LIST_OPEN, this._isOptionsListVisible);
this.classList.toggle(MSR_STATE_ITEM_STAGED, !!this._stagedItem);
}
_render() {
const componentId = this.id || `msr-${crypto.randomUUID().slice(0, 8)}`; // Changed prefix
if (!this.id) {
this.setAttribute("id", componentId);
}
this.innerHTML = `
<style>
.${MSR_HIDDEN_SELECT_CLASS} {
display: none !important;
visibility: hidden !important;
position: absolute !important;
width: 0 !important;
height: 0 !important;
opacity: 0 !important;
pointer-events: none !important;
}
</style>
<div class="${MSR_COMPONENT_WRAPPER_CLASS} relative">
<div class="${MSR_SELECTED_ITEMS_CONTAINER_CLASS} flex flex-wrap gap-2 mb-2 min-h-[42px]" aria-live="polite"></div>
<div class="${MSR_CONTROLS_AREA_CLASS} flex items-center space-x-2">
<div class="${MSR_INPUT_AREA_WRAPPER_CLASS} flex-grow min-h-[42px] flex items-center flex-wrap gap-1 p-1 rounded-md" tabindex="-1">
<span class="${MSR_STAGING_AREA_CONTAINER_CLASS} flex items-center gap-2"></span>
<input type="text" class="${MSR_TEXT_INPUT_CLASS} flex-1 min-w-[60px] outline-none bg-transparent" placeholder="Search...">
</div>
<button type="button" class="${MSR_ADD_BUTTON_CLASS} hidden px-4 py-2 text-sm rounded-md">Add</button>
</div>
<ul role="listbox" class="${MSR_OPTIONS_LIST_CLASS} absolute z-20 w-full max-h-60 overflow-y-auto mt-1 hidden"></ul>
<select multiple name="${this.getAttribute("name") || "items_with_roles_default"}" id="hidden-select-${componentId}" class="${MSR_HIDDEN_SELECT_CLASS}" aria-hidden="true"></select>
</div>
`;
}
_createStagedItemPillElement(itemData) {
// Renamed from _createStagedPlacePillElement
const fragment = this.stagedPlacePillTemplate.content.cloneNode(true); // Template name can remain for structure
const pill = fragment.firstElementChild;
pill.classList.add(MSR_STAGED_ITEM_PILL_CLASS);
pill.querySelector('[data-ref="nameEl"]').textContent = itemData.name;
return pill;
}
_createStagedRoleSelectElement(availableRoles, currentRole) {
const roleSelect = document.createElement("select");
roleSelect.className = `${MSR_STAGED_ROLE_SELECT_CLASS} px-2 py-1 text-sm rounded-md border`;
const placeholderOption = document.createElement("option");
placeholderOption.value = "";
placeholderOption.textContent = "Select Role...";
placeholderOption.disabled = true;
if (!currentRole) {
placeholderOption.selected = true;
}
roleSelect.appendChild(placeholderOption);
if (availableRoles.length === 0 && !this._roles.includes(currentRole)) {
const noRoleOption = document.createElement("option");
noRoleOption.textContent = "No roles left";
noRoleOption.disabled = true;
roleSelect.appendChild(noRoleOption);
roleSelect.disabled = true;
} else {
availableRoles.forEach((roleName) => {
const option = document.createElement("option");
option.value = roleName;
option.textContent = roleName;
if (roleName === currentRole) {
option.selected = true;
}
roleSelect.appendChild(option);
});
roleSelect.disabled = availableRoles.length === 0 && currentRole === "";
}
roleSelect.addEventListener("change", this._handleStagedRoleChange);
return roleSelect;
}
_createStagedCancelButtonElement(itemName) {
// Renamed from placeName
const fragment = this.stagedCancelBtnTemplate.content.cloneNode(true);
const button = fragment.firstElementChild;
button.setAttribute("aria-label", `Cancel staging ${itemName}`);
button.addEventListener("click", this._handleCancelStagedItem);
return button;
}
_renderStagedPillOrInput() {
if (!this.stagedItemPillContainer || !this.inputElement || !this.inputAreaWrapper) return;
this.stagedItemPillContainer.innerHTML = "";
if (this._stagedItem && this._stagedItem.item) {
// Changed from .place to .item
this.inputAreaWrapper.classList.remove(
MSR_INPUT_AREA_WRAPPER_DEFAULT_BORDER_CLASS,
"border",
"border-gray-300",
"focus-within:border-blue-500",
"focus-within:ring-1",
"focus-within:ring-blue-500",
);
this.inputAreaWrapper.classList.add(
MSR_INPUT_AREA_WRAPPER_STAGED_CLASS,
"border",
"border-transparent",
);
const itemPill = this._createStagedItemPillElement(this._stagedItem.item);
this.stagedItemPillContainer.appendChild(itemPill);
const availableRoles = this._getAvailableRolesForItem(this._stagedItem.item.id);
const roleSelect = this._createStagedRoleSelectElement(
availableRoles,
this._stagedItem.currentRole,
);
this.stagedItemPillContainer.appendChild(roleSelect);
const cancelBtn = this._createStagedCancelButtonElement(this._stagedItem.item.name);
this.stagedItemPillContainer.appendChild(cancelBtn);
this.inputElement.classList.add("hidden");
this.inputElement.value = "";
} else {
this.inputAreaWrapper.classList.add(
MSR_INPUT_AREA_WRAPPER_DEFAULT_BORDER_CLASS,
"border",
"border-gray-300",
"focus-within:border-blue-500",
"focus-within:ring-1",
"focus-within:ring-blue-500",
);
this.inputAreaWrapper.classList.remove(
MSR_INPUT_AREA_WRAPPER_STAGED_CLASS,
"border-transparent",
);
this.inputElement.classList.remove("hidden");
}
this._updateAddButtonState();
this._updateRootElementStateClasses();
}
_handleStagedRoleChange(event) {
if (this._stagedItem) {
this._stagedItem.currentRole = event.target.value;
this._updateAddButtonState();
event.target.focus();
}
}
_handleCancelStagedItem(event) {
if (event) event.stopPropagation();
this._stagedItem = null;
this._renderStagedPillOrInput();
this._updateAddButtonState();
if (this.inputElement) {
this.inputElement.value = "";
this.inputElement.focus();
}
this._hideOptionsList();
this._updateRootElementStateClasses();
}
_createSelectedItemElement(valueItem) {
// valueItem is {itemId, role, instanceId}
const itemData = this._getItemById(valueItem.itemId);
if (!itemData) return null;
const fragment = this.selectedItemTemplate.content.cloneNode(true);
const itemEl = fragment.firstElementChild;
itemEl.querySelector('[data-ref="textEl"]').textContent =
`${itemData.name} ${itemData.additional_data ? "(" + itemData.additional_data + ")" : ""} - ${valueItem.role}`;
const deleteBtn = itemEl.querySelector('[data-ref="deleteBtn"]');
deleteBtn.setAttribute("aria-label", `Remove ${itemData.name} as ${valueItem.role}`);
deleteBtn.dataset.instanceId = valueItem.instanceId;
deleteBtn.disabled = this.hasAttribute("disabled");
deleteBtn.addEventListener("click", (e) => {
e.stopPropagation();
this._handleDeleteSelectedItem(valueItem.instanceId);
});
return itemEl;
}
_renderSelectedItems() {
if (!this.selectedItemsContainer) return;
this.selectedItemsContainer.innerHTML = "";
this._value.forEach((item) => {
const itemEl = this._createSelectedItemElement(item);
if (itemEl) {
this.selectedItemsContainer.appendChild(itemEl);
}
});
if (this._value.length === 0) {
this.selectedItemsContainer.innerHTML = `<span class="italic">No items selected</span>`;
}
this._updateRootElementStateClasses();
}
_updateAddButtonState() {
if (this.addButtonElement) {
const isComponentDisabled = this.hasAttribute("disabled");
const isValidRoleSelected =
this._stagedItem &&
this._stagedItem.currentRole &&
this._roles.includes(this._stagedItem.currentRole);
const shouldBeHiddenOrDisabled =
!this._stagedItem || !isValidRoleSelected || isComponentDisabled;
this.addButtonElement.classList.toggle("hidden", shouldBeHiddenOrDisabled);
this.addButtonElement.disabled = shouldBeHiddenOrDisabled;
}
}
_createOptionElement(itemData, index) {
// itemData instead of place
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})` : ""; // Use additional_data
li.dataset.id = itemData.id;
li.setAttribute("aria-selected", String(index === this._highlightedIndex));
if (index === this._highlightedIndex) {
li.classList.add(MSR_HIGHLIGHTED_OPTION_CLASS);
li.id = `highlighted-option-${this.id || "multi-select-role"}`;
// User styles MSP_HIGHLIGHTED_OPTION_CLASS and its effect on MSP_OPTION_ITEM_DETAIL_CLASS
}
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((itemData, index) => {
const optionEl = this._createOptionElement(itemData, index);
this.optionsListElement.appendChild(optionEl);
});
const highlightedElement = this.optionsListElement.querySelector(
`#highlighted-option-${this.id || "multi-select-role"}`,
);
if (highlightedElement) {
highlightedElement.scrollIntoView({ block: "nearest" });
}
}
this._updateRootElementStateClasses();
}
_stageItem(itemData) {
// itemData instead of item
const availableRoles = this._getAvailableRolesForItem(itemData.id);
if (availableRoles.length === 0) {
console.warn(`Cannot stage ${itemData.name}, no available roles.`);
return;
}
this._stagedItem = { item: itemData, currentRole: "" }; // Changed from place to item
if (this.inputElement) this.inputElement.value = "";
this._renderStagedPillOrInput();
this._updateAddButtonState();
this._hideOptionsList();
const roleSelect = this.stagedItemPillContainer.querySelector("select");
if (roleSelect && !roleSelect.disabled) {
roleSelect.focus();
} else if (this.addButtonElement && !this.addButtonElement.disabled) {
this.addButtonElement.focus();
}
this._updateRootElementStateClasses();
}
_handleAddButtonClick() {
if (
this._stagedItem &&
this._stagedItem.item &&
this._stagedItem.currentRole &&
this._roles.includes(this._stagedItem.currentRole)
) {
const newItem = {
itemId: this._stagedItem.item.id, // Changed from placeId
role: this._stagedItem.currentRole,
instanceId: crypto.randomUUID(),
};
const alreadyExists = this._value.find(
(v) => v.itemId === newItem.itemId && v.role === newItem.role,
);
if (alreadyExists) {
console.warn("Attempted to add duplicate item-role combination:", newItem);
this._handleCancelStagedItem();
return;
}
this._value.push(newItem);
this._updateFormValue();
this._renderSelectedItems();
this._stagedItem = null;
this._renderStagedPillOrInput();
this._updateAddButtonState();
if (this.inputElement) {
this.inputElement.value = "";
this.inputElement.focus();
}
this._hideOptionsList();
} else {
console.warn("Add button clicked but staged item or role is invalid.", this._stagedItem);
}
this._updateRootElementStateClasses();
}
_handleInput(event) {
if (this._stagedItem) {
this._stagedItem = null;
this._renderStagedPillOrInput();
this._updateAddButtonState();
}
const searchTerm = event.target.value;
if (searchTerm.length === 0) {
this._filteredOptions = [];
this._isOptionsListVisible = false;
} else {
const searchTermLower = searchTerm.toLowerCase();
this._filteredOptions = this._options.filter((itemData) => {
// itemData instead of place
const availableRoles = this._getAvailableRolesForItem(itemData.id);
if (availableRoles.length === 0) {
return false;
}
if (this._stagedItem && this._stagedItem.item.id === itemData.id) return false;
return (
itemData.name.toLowerCase().includes(searchTermLower) ||
(itemData.additional_data &&
itemData.additional_data.toLowerCase().includes(searchTermLower))
); // Use additional_data
});
this._isOptionsListVisible = this._filteredOptions.length > 0;
}
this._highlightedIndex = this._filteredOptions.length > 0 ? 0 : -1;
this._renderOptionsList();
}
_handleKeyDown(event) {
if (event.key === "Escape") {
if (this._isOptionsListVisible) {
event.preventDefault();
this._hideOptionsList();
if (this.inputElement) this.inputElement.focus();
} else if (this._stagedItem) {
event.preventDefault();
this._handleCancelStagedItem(event);
}
}
}
_handleInputKeyDown(event) {
if (this.inputElement && this.inputElement.disabled) return;
if (
event.key === "Backspace" &&
this.inputElement &&
this.inputElement.value === "" &&
!this._stagedItem &&
this._value.length > 0
) {
event.preventDefault();
this._handleDeleteSelectedItem(this._value[this._value.length - 1].instanceId);
return;
}
if (
this._stagedItem &&
this._stagedItem.currentRole &&
this._roles.includes(this._stagedItem.currentRole) &&
event.key === "Enter"
) {
if (
document.activeElement === this.addButtonElement ||
(this.stagedItemPillContainer &&
this.stagedItemPillContainer.contains(document.activeElement))
) {
event.preventDefault();
this._handleAddButtonClick();
return;
}
}
if (!this._isOptionsListVisible || this._filteredOptions.length === 0) {
if (event.key === "Enter" && this.inputElement && this.inputElement.value === "") {
event.preventDefault();
}
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":
case "Tab":
event.preventDefault();
if (this._highlightedIndex > -1 && this._filteredOptions[this._highlightedIndex]) {
this._stageItem(this._filteredOptions[this._highlightedIndex]);
} else if (event.key === "Tab") {
this._hideOptionsList();
}
break;
}
}
_hideOptionsList() {
this._isOptionsListVisible = false;
this._highlightedIndex = -1;
if (this.optionsListElement) this._renderOptionsList();
}
_handleFocus() {
if (this.inputElement && (this.inputElement.disabled || this._stagedItem)) return;
if (!this._stagedItem && this.inputAreaWrapper) {
this.inputAreaWrapper.classList.add(
MSR_INPUT_AREA_WRAPPER_DEFAULT_BORDER_CLASS,
"border",
"border-gray-300",
"focus-within:border-blue-500",
"focus-within:ring-1",
"focus-within:ring-blue-500",
);
this.inputAreaWrapper.classList.remove(
MSR_INPUT_AREA_WRAPPER_STAGED_CLASS,
"border-transparent",
);
}
if (this.inputElement && this.inputElement.value.length > 0) {
const searchTerm = this.inputElement.value.toLowerCase();
this._filteredOptions = this._options.filter((itemData) => {
// itemData instead of place
const availableRoles = this._getAvailableRolesForItem(itemData.id);
if (availableRoles.length === 0) return false;
if (this._stagedItem && this._stagedItem.item.id === itemData.id) return false;
return (
itemData.name.toLowerCase().includes(searchTerm) ||
(itemData.additional_data && itemData.additional_data.toLowerCase().includes(searchTerm))
);
});
if (this._filteredOptions.length > 0) {
this._isOptionsListVisible = true;
this._highlightedIndex = 0;
this._renderOptionsList();
} else {
this._hideOptionsList();
}
} else {
this._hideOptionsList();
}
this._updateRootElementStateClasses();
}
_blurTimeout = null;
_handleBlur(event) {
if (!this._stagedItem && this.inputAreaWrapper) {
// Border styling is handled by adding/removing classes in _renderStagedPillOrInput
}
this._blurTimeout = setTimeout(() => {
const activeEl = document.activeElement;
if (
activeEl !== this.addButtonElement &&
!(this.stagedItemPillContainer && this.stagedItemPillContainer.contains(activeEl)) &&
!(this.optionsListElement && this.optionsListElement.contains(activeEl)) &&
!this.contains(activeEl)
) {
this._hideOptionsList();
}
}, 150);
}
_handleOptionMouseDown(event) {
event.preventDefault();
}
_handleOptionClick(event) {
const li = event.target.closest("li[data-id]");
if (li) {
const itemId = li.dataset.id; // itemId instead of placeId
const itemToStage = this._filteredOptions.find((opt) => opt.id === itemId);
if (itemToStage) {
this._stageItem(itemToStage);
}
}
}
_handleDeleteSelectedItem(instanceId) {
this._value = this._value.filter((item) => item.instanceId !== instanceId);
this._updateFormValue();
this._renderSelectedItems();
if (this.inputElement) {
this.inputElement.focus();
this._handleInput({ target: this.inputElement });
}
}
}
customElements.define("multi-select-role", MultiSelectRole); // Renamed custom element definition
// --- Demo Page Script ---
const myForm = document.getElementById("myForm");
const formDataDisplay = document.getElementById("form-data-display");
const formDataPre = formDataDisplay.querySelector("pre");
myForm.addEventListener("submit", function (event) {
event.preventDefault();
const formData = new FormData(this);
let output = "";
console.log("Form Submitted. FormData entries:");
for (let [name, value] of formData.entries()) {
console.log(`FormData - ${name}: ${value}`);
output += `FormData - ${name}: ${value}\n`;
}
const itemSelectEl = document.getElementById("item-select"); // Updated ID
const formattedValue = itemSelectEl.value
.map((item) => `(${item.itemId}, ${item.role})`)
.join("; ");
output += `\nCustom element .value (internal): [${formattedValue}]`;
const prefilledSelectEl = document.getElementById("place-select-prefilled"); // Keep old ID for demo or update
if (prefilledSelectEl) {
const prefilledValue = prefilledSelectEl.value; // This is an array of objects
if (Array.isArray(prefilledValue)) {
const formattedPrefilled = prefilledValue
.map((item) => `(${item.itemId || item.placeId}, ${item.role})`)
.join("; ");
output += `\nPrefilled element .value (internal): [${formattedPrefilled}]`;
}
}
formDataPre.textContent = output;
formDataDisplay.classList.remove("hidden");
});
myForm.addEventListener("reset", function (event) {
console.log("Form reset!");
formDataPre.textContent = "";
formDataDisplay.classList.add("hidden");
setTimeout(() => {
["item-select", "place-select-prefilled"].forEach((id) => {
// Updated ID
const el = document.getElementById(id);
if (el && typeof el.value !== "undefined") {
// Check if element and value exist
console.log(`${id} .value after reset:`, el.value);
if (el._stagedItem)
console.warn(`${id} still has staged item after reset:`, el._stagedItem);
if (el.hiddenSelect) {
const hiddenOptions = Array.from(el.hiddenSelect.options).map((opt) => opt.value);
console.log(`${id} hidden select values:`, hiddenOptions);
}
}
});
}, 0);
});

View File

@@ -0,0 +1,517 @@
// --- 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 = `
<li role="option" class="${MSS_OPTION_ITEM_CLASS} px-3 py-2 text-sm cursor-pointer transition-colors duration-75 group">
<span data-ref="nameEl" class="${MSS_OPTION_ITEM_NAME_CLASS} font-semibold"></span>
<span data-ref="detailEl" class="${MSS_OPTION_ITEM_DETAIL_CLASS} text-xs ml-2"></span>
</li>
`;
this.selectedItemTemplate = document.createElement("template");
this.selectedItemTemplate.innerHTML = `
<span class="${MSS_SELECTED_ITEM_PILL_CLASS} flex items-center">
<span data-ref="textEl" class="${MSS_SELECTED_ITEM_TEXT_CLASS}"></span>
<button type="button" data-ref="deleteBtn" class="${MSS_SELECTED_ITEM_DELETE_BTN_CLASS} ml-1.5 text-xs">&times;</button>
</span>
`;
}
_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 = `
<style> .${MSS_HIDDEN_SELECT_CLASS} { display: none !important; visibility: hidden !important; position: absolute !important; width: 0 !important; height: 0 !important; opacity: 0 !important; pointer-events: none !important; } </style>
<div class="${MSS_COMPONENT_WRAPPER_CLASS} relative">
<div class="${MSS_SELECTED_ITEMS_CONTAINER_CLASS} flex flex-wrap gap-1 mb-1 min-h-[38px]" aria-live="polite"></div>
<div class="${MSS_INPUT_CONTROLS_CONTAINER_CLASS} flex items-center space-x-2">
<div class="${MSS_INPUT_WRAPPER_CLASS} relative rounded-md flex items-center flex-grow">
<input type="text" class="${MSS_TEXT_INPUT_CLASS} w-full outline-none bg-transparent p-1.5 text-sm" placeholder="Search items..."/>
</div>
<button type="button" class="${MSS_CREATE_NEW_BUTTON_CLASS} px-2 py-1 text-xs rounded-sm"> +
</button>
</div>
<ul role="listbox" class="${MSS_OPTIONS_LIST_CLASS} absolute z-20 w-full max-h-60 overflow-y-auto mt-1 hidden"></ul>
<select multiple name="${this.getAttribute("name") || "simple_items_default"}" id="hidden-select-${componentId}" class="${MSS_HIDDEN_SELECT_CLASS}" aria-hidden="true"></select>
</div>
`;
}
_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 = `<span class="italic text-xs">No items selected</span>`;
}
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);
});