This commit is contained in:
Simon Martens
2026-01-07 20:03:49 +01:00
parent f9fb077518
commit 54a6714e76
9 changed files with 1692 additions and 924 deletions

View File

@@ -189,7 +189,7 @@
}
.mss-selected-item-pill {
@apply bg-gray-200 text-gray-800 py-0.5 px-2 rounded text-xs leading-5; /* Adjusted font size and padding */
@apply bg-gray-200 text-gray-800 py-0.5 px-2 rounded leading-5 shadow-xs hover:shadow select-none; /* Adjusted font size and padding */
/* Tailwind classes from component: flex items-center */
}
@@ -204,7 +204,7 @@
}
.mss-selected-item-delete-btn {
@apply bg-transparent border-none text-gray-600 opacity-70 cursor-pointer ml-1 text-base leading-none align-middle hover:opacity-100 hover:text-gray-900 disabled:opacity-40 disabled:cursor-not-allowed;
@apply bg-transparent border-none text-gray-600 opacity-70 cursor-pointer ml-2 text-lg leading-none align-middle hover:opacity-100 hover:text-gray-900 disabled:opacity-40 disabled:cursor-not-allowed;
}
.mss-input-controls-container {
@@ -220,7 +220,7 @@
}
.mss-text-input {
@apply py-1.5 px-2 text-sm;
@apply py-1.5 px-2;
/* Tailwind classes from component: w-full outline-none bg-transparent */
}
.mss-text-input::placeholder {
@@ -235,12 +235,12 @@
}
.mss-options-list {
@apply bg-white border border-gray-300 rounded shadow-md; /* Using shadow-md as a softer default */
@apply bg-white border border-gray-300 rounded shadow-md list-none m-0; /* Using shadow-md as a softer default */
/* Tailwind classes from component: absolute z-20 w-full max-h-60 overflow-y-auto mt-1 hidden */
}
.mss-option-item {
@apply text-gray-700 py-1.5 px-2.5 text-sm cursor-pointer transition-colors duration-75 hover:bg-gray-100;
@apply text-gray-700 py-1.5 px-2.5 cursor-pointer transition-colors duration-75 hover:bg-gray-100 list-none m-0;
}
.mss-option-item-name {
@@ -248,7 +248,7 @@
}
.mss-option-item-detail {
@apply text-gray-500 text-xs ml-1.5;
@apply text-gray-500 ml-1.5;
}
.mss-option-item-highlighted {

View File

@@ -0,0 +1,280 @@
const ROW_CLASS = "items-row";
const LIST_CLASS = "items-list";
const TEMPLATE_CLASS = "items-template";
const ADD_BUTTON_CLASS = "items-add-button";
const REMOVE_BUTTON_CLASS = "items-remove-button";
const EDIT_BUTTON_CLASS = "items-edit-button";
const CLOSE_BUTTON_CLASS = "items-close-button";
const SUMMARY_CLASS = "items-summary";
const EDIT_PANEL_CLASS = "items-edit-panel";
const REMOVED_INPUT_NAME = "items_removed[]";
export class ItemsEditor extends HTMLElement {
constructor() {
super();
this._list = null;
this._template = null;
this._addButton = null;
this._idPrefix = `items-editor-${crypto.randomUUID().slice(0, 8)}`;
this._handleAdd = this._onAddClick.bind(this);
}
connectedCallback() {
this._list = this.querySelector(`.${LIST_CLASS}`);
this._template = this.querySelector(`template.${TEMPLATE_CLASS}`);
this._addButton = this.querySelector(`.${ADD_BUTTON_CLASS}`);
if (!this._list || !this._template || !this._addButton) {
console.error("ItemsEditor: Missing list, template, or add button.");
return;
}
this._addButton.addEventListener("click", this._handleAdd);
this._wireRemoveButtons();
this._wireEditButtons();
this._refreshRowIds();
this._syncAllSummaries();
}
disconnectedCallback() {
if (this._addButton) {
this._addButton.removeEventListener("click", this._handleAdd);
}
}
_onAddClick(event) {
event.preventDefault();
this.addItem();
}
addItem() {
const fragment = this._template.content.cloneNode(true);
const newRow = fragment.querySelector(`.${ROW_CLASS}`);
if (!newRow) {
console.error("ItemsEditor: Template is missing a row element.");
return;
}
this._list.appendChild(fragment);
this._wireRemoveButtons(newRow);
this._wireEditButtons(newRow);
this._assignRowFieldIds(newRow, this._rowIndex(newRow));
this._wireSummarySync(newRow);
this._syncSummary(newRow);
this._setRowMode(newRow, "edit");
}
removeItem(button) {
const row = button.closest(`.${ROW_CLASS}`);
if (!row) {
return;
}
const idInput = row.querySelector('input[name="items_id[]"]');
const itemId = idInput ? idInput.value.trim() : "";
if (itemId) {
this._ensureRemovalInput(itemId);
}
row.remove();
this._refreshRowIds();
}
_wireRemoveButtons(root = this) {
root.querySelectorAll(`.${REMOVE_BUTTON_CLASS}`).forEach((btn) => {
if (btn.dataset.itemsBound === "true") {
return;
}
btn.dataset.itemsBound = "true";
btn.addEventListener("click", (event) => {
event.preventDefault();
this.removeItem(btn);
});
});
}
_wireEditButtons(root = this) {
root.querySelectorAll(`.${EDIT_BUTTON_CLASS}`).forEach((btn) => {
if (btn.dataset.itemsBound === "true") {
return;
}
btn.dataset.itemsBound = "true";
btn.addEventListener("click", (event) => {
event.preventDefault();
const row = btn.closest(`.${ROW_CLASS}`);
if (row) {
this._setRowMode(row, "edit");
}
});
});
root.querySelectorAll(`.${CLOSE_BUTTON_CLASS}`).forEach((btn) => {
if (btn.dataset.itemsBound === "true") {
return;
}
btn.dataset.itemsBound = "true";
btn.addEventListener("click", (event) => {
event.preventDefault();
const row = btn.closest(`.${ROW_CLASS}`);
if (row) {
this._setRowMode(row, "summary");
}
});
});
}
_setRowMode(row, mode) {
const summary = row.querySelector(`.${SUMMARY_CLASS}`);
const editor = row.querySelector(`.${EDIT_PANEL_CLASS}`);
if (!summary || !editor) {
return;
}
if (mode === "edit") {
summary.classList.add("hidden");
editor.classList.remove("hidden");
} else {
summary.classList.remove("hidden");
editor.classList.add("hidden");
this._syncSummary(row);
}
}
_refreshRowIds() {
const rows = Array.from(this.querySelectorAll(`.${ROW_CLASS}`));
rows.forEach((row, index) => {
this._assignRowFieldIds(row, index);
});
}
_rowIndex(row) {
const rows = Array.from(this.querySelectorAll(`.${ROW_CLASS}`));
return rows.indexOf(row);
}
_assignRowFieldIds(row, index) {
if (index < 0) {
return;
}
row.querySelectorAll("[data-field-label]").forEach((label) => {
const fieldName = label.getAttribute("data-field-label");
if (!fieldName) {
return;
}
const field = row.querySelector(`[data-field="${fieldName}"]`);
if (!field) {
return;
}
const fieldId = `${this._idPrefix}-${index}-${fieldName}`;
field.id = fieldId;
label.setAttribute("for", fieldId);
});
}
_syncAllSummaries() {
this.querySelectorAll(`.${ROW_CLASS}`).forEach((row) => {
this._wireSummarySync(row);
this._syncSummary(row);
});
}
_wireSummarySync(row) {
if (row.dataset.summaryBound === "true") {
return;
}
row.dataset.summaryBound = "true";
row.querySelectorAll("[data-field]").forEach((field) => {
field.addEventListener("input", () => this._syncSummary(row));
field.addEventListener("change", () => this._syncSummary(row));
});
}
_syncSummary(row) {
row.querySelectorAll("[data-summary-field]").forEach((summaryField) => {
const fieldName = summaryField.getAttribute("data-summary-field");
if (!fieldName) {
return;
}
const formField = row.querySelector(`[data-field="${fieldName}"]`);
if (!formField) {
return;
}
const value = this._readFieldValue(formField);
const container =
summaryField.getAttribute("data-summary-hide-empty") === "true"
? summaryField.closest("[data-summary-container]")
: null;
if (value) {
this._setSummaryContent(summaryField, value);
summaryField.classList.remove("text-gray-400");
if (container) {
container.classList.remove("hidden");
}
} else {
this._setSummaryContent(summaryField, "—");
summaryField.classList.add("text-gray-400");
if (container) {
container.classList.add("hidden");
}
}
});
}
_setSummaryContent(summaryField, value) {
const link = summaryField.querySelector("[data-summary-link]");
if (link) {
if (value && value !== "—") {
link.setAttribute("href", value);
link.textContent = value;
} else {
link.setAttribute("href", "#");
link.textContent = "—";
}
} else {
summaryField.textContent = value || "—";
}
}
_readFieldValue(field) {
if (field instanceof HTMLSelectElement) {
if (field.multiple) {
return Array.from(field.selectedOptions)
.map((opt) => opt.textContent.trim())
.filter(Boolean)
.join(", ");
}
const selected = field.selectedOptions[0];
return selected ? selected.textContent.trim() : "";
}
if (field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) {
return field.value.trim();
}
return "";
}
_ensureRemovalInput(itemId) {
const existing = Array.from(this.querySelectorAll(`input[name="${REMOVED_INPUT_NAME}"]`)).some(
(input) => input.value === itemId,
);
if (existing) {
return;
}
const hidden = document.createElement("input");
hidden.type = "hidden";
hidden.name = REMOVED_INPUT_NAME;
hidden.value = itemId;
this.appendChild(hidden);
}
}

View File

@@ -17,6 +17,11 @@ 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";
const MSS_NO_ITEMS_TEXT_CLASS = "mss-no-items-text";
const MSS_LOADING_CLASS = "mss-loading";
const MSS_REMOTE_DEFAULT_MIN_CHARS = 1;
const MSS_REMOTE_DEFAULT_LIMIT = 10;
const MSS_REMOTE_FETCH_DEBOUNCE_MS = 250;
// State classes for MultiSelectSimple
const MSS_STATE_NO_SELECTION = "mss-state-no-selection";
@@ -220,6 +225,13 @@ export class MultiSelectSimple extends HTMLElement {
this._highlightedIndex = -1;
this._isOptionsListVisible = false;
this._remoteEndpoint = null;
this._remoteResultKey = "items";
this._remoteMinChars = MSS_REMOTE_DEFAULT_MIN_CHARS;
this._remoteLimit = MSS_REMOTE_DEFAULT_LIMIT;
this._remoteFetchController = null;
this._remoteFetchTimeout = null;
this._placeholder = this.getAttribute("placeholder") || "Search items...";
this._showCreateButton = this.getAttribute("show-create-button") !== "false";
@@ -284,7 +296,12 @@ export class MultiSelectSimple extends HTMLElement {
setOptions(newOptions) {
if (Array.isArray(newOptions) && newOptions.every((o) => o && typeof o.id === "string" && typeof o.name === "string")) {
this._options = [...newOptions];
this._options = newOptions.map((option) => {
const normalized = { ...option };
normalized.name = this._normalizeText(normalized.name);
normalized.additional_data = this._normalizeText(normalized.additional_data);
return normalized;
});
const validValues = this._value.filter((id) => this._getItemById(id));
if (validValues.length !== this._value.length) this.value = validValues;
else if (this.selectedItemsContainer) this._renderSelectedItems();
@@ -336,6 +353,10 @@ export class MultiSelectSimple extends HTMLElement {
this.placeholder = this.getAttribute("placeholder") || "Search items...";
this.showCreateButton = this.getAttribute("show-create-button") !== "false";
this._remoteEndpoint = this.getAttribute("data-endpoint") || null;
this._remoteResultKey = this.getAttribute("data-result-key") || "items";
this._remoteMinChars = this._parsePositiveInt(this.getAttribute("data-minchars"), MSS_REMOTE_DEFAULT_MIN_CHARS);
this._remoteLimit = this._parsePositiveInt(this.getAttribute("data-limit"), MSS_REMOTE_DEFAULT_LIMIT);
if (this.name && this.hiddenSelect) this.hiddenSelect.name = this.name;
@@ -380,10 +401,15 @@ export class MultiSelectSimple extends HTMLElement {
if (this.createNewButton) this.createNewButton.removeEventListener("click", this._handleCreateNewButtonClick);
if (this.selectedItemsContainer) this.selectedItemsContainer.removeEventListener("click", this._handleSelectedItemsContainerClick);
clearTimeout(this._blurTimeout);
if (this._remoteFetchTimeout) {
clearTimeout(this._remoteFetchTimeout);
this._remoteFetchTimeout = null;
}
this._cancelRemoteFetch();
}
static get observedAttributes() {
return ["disabled", "name", "value", "placeholder", "show-create-button"];
return ["disabled", "name", "value", "placeholder", "show-create-button", "data-endpoint", "data-result-key", "data-minchars", "data-limit"];
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return;
@@ -400,6 +426,10 @@ export class MultiSelectSimple extends HTMLElement {
}
} else if (name === "placeholder") this.placeholder = newValue;
else if (name === "show-create-button") this.showCreateButton = newValue;
else if (name === "data-endpoint") this._remoteEndpoint = newValue || null;
else if (name === "data-result-key") this._remoteResultKey = newValue || "items";
else if (name === "data-minchars") this._remoteMinChars = this._parsePositiveInt(newValue, MSS_REMOTE_DEFAULT_MIN_CHARS);
else if (name === "data-limit") this._remoteLimit = this._parsePositiveInt(newValue, MSS_REMOTE_DEFAULT_LIMIT);
}
formAssociatedCallback(form) {}
@@ -458,10 +488,10 @@ export class MultiSelectSimple extends HTMLElement {
</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" tabindex="-1"></div>
<div class="${MSS_INPUT_CONTROLS_CONTAINER_CLASS} flex items-center space-x-2">
<div class="${MSS_INPUT_CONTROLS_CONTAINER_CLASS} flex items-center space-x-4">
<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 text-sm"
class="${MSS_TEXT_INPUT_CLASS} w-full outline-none bg-transparent"
placeholder="${this.placeholder}"
aria-autocomplete="list"
aria-expanded="${this._isOptionsListVisible}"
@@ -483,11 +513,13 @@ export class MultiSelectSimple extends HTMLElement {
const textEl = pillEl.querySelector('[data-ref="textEl"]');
const detailEl = pillEl.querySelector('[data-ref="detailEl"]'); // This now uses MSS_SELECTED_ITEM_PILL_DETAIL_CLASS
const deleteBtn = pillEl.querySelector('[data-ref="deleteBtn"]');
textEl.textContent = itemData.name;
if (itemData.additional_data) {
detailEl.textContent = `(${itemData.additional_data})`;
textEl.textContent = this._normalizeText(itemData.name);
const detailText = this._normalizeText(itemData.additional_data);
if (detailText) {
detailEl.textContent = `(${detailText})`;
detailEl.classList.remove("hidden"); // Toggle visibility via JS
} else {
detailEl.textContent = "";
detailEl.classList.add("hidden"); // Toggle visibility via JS
}
deleteBtn.setAttribute("aria-label", `Remove ${itemData.name}`);
@@ -517,8 +549,9 @@ export class MultiSelectSimple extends HTMLElement {
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})` : "";
nameEl.textContent = this._normalizeText(itemData.name);
const detailText = this._normalizeText(itemData.additional_data);
detailEl.textContent = detailText ? `(${detailText})` : "";
li.dataset.id = itemData.id;
li.setAttribute("aria-selected", String(index === this._highlightedIndex));
const optionElementId = `option-${this.id || "mss"}-${itemData.id}`;
@@ -569,6 +602,10 @@ export class MultiSelectSimple extends HTMLElement {
}
_handleInput(event) {
const searchTerm = event.target.value;
if (this._remoteEndpoint) {
this._handleRemoteInput(searchTerm);
return;
}
if (searchTerm.length === 0) {
this._filteredOptions = [];
this._isOptionsListVisible = false;
@@ -576,8 +613,10 @@ export class MultiSelectSimple extends HTMLElement {
const searchTermLower = searchTerm.toLowerCase();
this._filteredOptions = this._options.filter((item) => {
if (this._value.includes(item.id)) return false;
const nameMatch = item.name.toLowerCase().includes(searchTermLower);
const additionalDataMatch = item.additional_data && item.additional_data.toLowerCase().includes(searchTermLower);
const normalizedName = this._normalizeText(item.name);
const nameMatch = normalizedName.toLowerCase().includes(searchTermLower);
const detailValue = this._normalizeText(item.additional_data);
const additionalDataMatch = detailValue && detailValue.toLowerCase().includes(searchTermLower);
return nameMatch || additionalDataMatch;
});
this._isOptionsListVisible = this._filteredOptions.length > 0;
@@ -668,4 +707,161 @@ export class MultiSelectSimple extends HTMLElement {
if (this.inputElement && this.inputElement.value) this._handleInput({ target: this.inputElement });
if (this.inputElement && !this.hasAttribute("disabled")) this.inputElement.focus();
}
_parsePositiveInt(value, fallback) {
if (!value) return fallback;
const parsed = parseInt(value, 10);
if (Number.isNaN(parsed) || parsed <= 0) {
return fallback;
}
return parsed;
}
_handleRemoteInput(searchTerm) {
if (this._remoteFetchTimeout) {
clearTimeout(this._remoteFetchTimeout);
}
if (searchTerm.length < this._remoteMinChars) {
this._filteredOptions = [];
this._isOptionsListVisible = false;
this._renderOptionsList();
return;
}
this._remoteFetchTimeout = setTimeout(() => {
this._fetchRemoteOptions(searchTerm);
}, MSS_REMOTE_FETCH_DEBOUNCE_MS);
}
_cancelRemoteFetch() {
if (this._remoteFetchController) {
this._remoteFetchController.abort();
this._remoteFetchController = null;
}
}
async _fetchRemoteOptions(searchTerm) {
if (!this._remoteEndpoint) return;
this._cancelRemoteFetch();
this.classList.add(MSS_LOADING_CLASS);
const controller = new AbortController();
this._remoteFetchController = controller;
try {
const url = new URL(this._remoteEndpoint, window.location.origin);
url.searchParams.set("q", searchTerm);
if (this._remoteLimit) {
url.searchParams.set("limit", String(this._remoteLimit));
}
const response = await fetch(url.toString(), {
headers: { Accept: "application/json" },
signal: controller.signal,
credentials: "same-origin",
});
if (!response.ok) {
throw new Error(`Remote fetch failed with status ${response.status}`);
}
const payload = await response.json();
if (controller.signal.aborted) {
return;
}
const options = this._extractRemoteOptions(payload);
this._applyRemoteResults(options);
} catch (error) {
if (controller.signal.aborted) {
return;
}
console.error("MultiSelectSimple remote fetch error:", error);
this._filteredOptions = [];
this._isOptionsListVisible = false;
this._renderOptionsList();
} finally {
if (this._remoteFetchController === controller) {
this._remoteFetchController = null;
}
this.classList.remove(MSS_LOADING_CLASS);
}
}
_extractRemoteOptions(payload) {
if (!payload) return [];
let entries = [];
if (Array.isArray(payload)) {
entries = payload;
} else if (this._remoteResultKey && Array.isArray(payload[this._remoteResultKey])) {
entries = payload[this._remoteResultKey];
} else if (Array.isArray(payload.items)) {
entries = payload.items;
}
return entries
.map((entry) => {
if (!entry) return null;
const id = entry.id ?? entry.ID ?? entry.value ?? "";
const name = entry.name ?? entry.title ?? entry.label ?? "";
const detail = entry.detail ?? entry.additional_data ?? entry.annotation ?? "";
const normalizedName = this._normalizeText(name);
const detailText = this._normalizeText(detail);
if (!id || !normalizedName) return null;
return {
id: String(id),
name: normalizedName,
additional_data: detailText,
};
})
.filter(Boolean);
}
_applyRemoteResults(options) {
const selected = new Set(this._value);
const merged = new Map();
this._options.forEach((opt) => {
if (opt?.id) {
merged.set(opt.id, opt);
}
});
options.forEach((opt) => {
if (opt?.id) {
merged.set(opt.id, opt);
}
});
this._options = Array.from(merged.values());
this._filteredOptions = options.filter((opt) => opt && !selected.has(opt.id));
this._isOptionsListVisible = this._filteredOptions.length > 0;
this._highlightedIndex = this._isOptionsListVisible ? 0 : -1;
this._renderOptionsList();
}
_normalizeText(rawValue) {
if (rawValue === null || rawValue === undefined) {
return "";
}
let text = String(rawValue).trim();
if (!text) {
return "";
}
const first = text[0];
const last = text[text.length - 1];
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
text = text.slice(1, -1).trim();
if (!text) {
return "";
}
}
return text;
}
}