select element completion

This commit is contained in:
Simon Martens
2025-05-29 23:47:55 +02:00
parent e4a07c62ab
commit 99c8996398
6 changed files with 1614 additions and 866 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,118 @@
document.addEventListener("DOMContentLoaded", function () {
console.log("DOM fully loaded. Initializing EasyMDE, custom extension, and CodeMirror overlay.");
// 1. Custom Marked.js extension (for PREVIEW)
const smallCapsExtension = {
name: "smallCapsDollar",
level: "inline",
start(src) {
const dollarIndex = src.indexOf("$");
if (dollarIndex !== -1 && src[dollarIndex + 1] !== "$") return dollarIndex;
return undefined;
},
tokenizer(src, tokens) {
const rule = /^\$([^$\n]+?)\$/;
const match = rule.exec(src);
if (match) {
const textContent = match[1].trim();
return { type: "smallCapsDollar", raw: match[0], text: textContent, tokens: [] };
}
return undefined;
},
renderer(token) {
return `<span class="rendered-small-caps">${token.text}</span>`;
},
};
// 2. CodeMirror Overlay Mode (for LIVE EDITING styling)
const smallCapsOverlay = {
token: function (stream, state) {
// Regex to match $text$
// It should match a '$', then non-'$' characters, then a closing '$'.
// The 'true' argument to stream.match consumes the matched text.
if (stream.match(/^\$[^$\n]+?\$/, true)) {
return "custom-small-caps"; // This will apply the .cm-custom-small-caps class
}
// If no match, advance the stream until a potential start of our pattern or EOL
// This is crucial to prevent infinite loops on non-matching characters.
while (stream.next() != null && !stream.match(/^\$[^$\n]+?\$/, false, false)) {
// The 'false, false' means: don't consume, case-insensitive is false (though $ isn't case sensitive)
// This loop advances char by char if our pattern isn't found immediately.
}
return null; // No special styling for other characters
},
};
try {
const easyMDE = new EasyMDE({
element: document.getElementById("my-markdown-editor"),
initialValue:
'# Live Small Caps Editing!\n\nThis text includes $small caps$ which should appear styled $while you type$.\n\nTry the "$S$" button too!',
spellChecker: false,
placeholder: "Type your Markdown here...",
renderingConfig: {
markedOptions: {
extensions: [smallCapsExtension],
},
},
toolbar: [
"bold",
"italic",
"heading",
"|",
"quote",
"unordered-list",
"ordered-list",
"|",
"link",
"image",
"|",
{
name: "insert-small-caps-syntax",
action: function (editor) {
const cm = editor.codemirror;
const selectedText = cm.getSelection();
if (selectedText) cm.replaceSelection(`$${selectedText}$`);
else {
cm.replaceSelection("$$");
const cursorPos = cm.getCursor();
cm.setCursor({ line: cursorPos.line, ch: cursorPos.ch - 1 });
}
cm.focus();
},
className: "fa fa-dollar-sign",
title: "Insert Small Caps Syntax ($text$)",
},
"|",
"preview",
"side-by-side",
"fullscreen",
"|",
"guide",
],
});
console.log("EasyMDE initialized.");
// 3. Add the overlay mode to CodeMirror instance
const cmInstance = easyMDE.codemirror;
if (cmInstance) {
cmInstance.addOverlay(smallCapsOverlay);
console.log("CodeMirror overlay for small caps added.");
} else {
console.warn("Could not get CodeMirror instance from EasyMDE.");
}
// --- Debugging Marked.js extension ---
const testMarkdownInput = "Test $small caps preview$.";
const renderedHtml = easyMDE.markdown(testMarkdownInput);
if (renderedHtml.includes('class="rendered-small-caps"')) {
console.log("Marked.js extension for preview is working.");
} else {
console.warn("Marked.js extension for preview might NOT be working.");
}
} catch (error) {
console.error("Error initializing EasyMDE or applying overlay:", error);
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -3,9 +3,11 @@ 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_PILL_DETAIL_CLASS = "mss-selected-item-pill-detail"; // New class for pill detail
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_CONTROLS_CONTAINER_CLASS = "mss-input-controls-container";
const MSS_INPUT_WRAPPER_CLASS = "mss-input-wrapper";
const MSS_INPUT_WRAPPER_FOCUSED_CLASS = "mss-input-wrapper-focused";
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";
@@ -14,6 +16,8 @@ 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";
const MSS_NO_ITEMS_TEXT_CLASS = "mss-no-items-text";
// State classes for MultiSelectSimple
const MSS_STATE_NO_SELECTION = "mss-state-no-selection";
const MSS_STATE_HAS_SELECTION = "mss-state-has-selection";
@@ -26,18 +30,34 @@ export class MultiSelectSimple extends HTMLElement {
constructor() {
super();
this.internals_ = this.attachInternals();
this._value = []; // Array of selected item IDs
this._value = [];
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" },
{ id: "marie_curie", name: "Marie Curie", additional_data: "Physicist and Chemist" },
{ id: "leonardo_da_vinci", name: "Leonardo da Vinci", additional_data: "Polymath" },
{ id: "albert_einstein", name: "Albert Einstein", additional_data: "Theoretical Physicist" },
{ id: "ada_lovelace", name: "Ada Lovelace", additional_data: "Mathematician and Writer" },
{ id: "isaac_newton", name: "Isaac Newton", additional_data: "Mathematician and Physicist" },
{
id: "galileo_galilei",
name: "Galileo Galilei",
additional_data: "Astronomer and Physicist",
},
{ id: "charles_darwin", name: "Charles Darwin", additional_data: "Naturalist" },
{ id: "nikola_tesla", name: "Nikola Tesla", additional_data: "Inventor and Engineer" },
{ id: "jane_austen", name: "Jane Austen", additional_data: "Novelist" },
{
id: "william_shakespeare",
name: "William Shakespeare",
additional_data: "Playwright and Poet",
},
];
this._filteredOptions = [];
this._highlightedIndex = -1;
this._isOptionsListVisible = false;
this._placeholder = this.getAttribute("placeholder") || "Search items...";
this._showCreateButton = this.getAttribute("show-create-button") !== "false";
this._setupTemplates();
this._bindEventHandlers();
}
@@ -45,17 +65,19 @@ export class MultiSelectSimple extends HTMLElement {
_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 role="option" class="${MSS_OPTION_ITEM_CLASS}">
<span data-ref="nameEl" class="${MSS_OPTION_ITEM_NAME_CLASS}"></span>
<span data-ref="detailEl" class="${MSS_OPTION_ITEM_DETAIL_CLASS}"></span>
</li>
`;
this.selectedItemTemplate = document.createElement("template");
// Apply new MSS_SELECTED_ITEM_PILL_DETAIL_CLASS to the detail span
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 data-ref="detailEl" class="${MSS_SELECTED_ITEM_PILL_DETAIL_CLASS} hidden"></span>
<button type="button" data-ref="deleteBtn" class="${MSS_SELECTED_ITEM_DELETE_BTN_CLASS}">&times;</button>
</span>
`;
}
@@ -68,13 +90,34 @@ export class MultiSelectSimple extends HTMLElement {
this._handleOptionMouseDown = this._handleOptionMouseDown.bind(this);
this._handleOptionClick = this._handleOptionClick.bind(this);
this._handleCreateNewButtonClick = this._handleCreateNewButtonClick.bind(this);
this._handleSelectedItemsContainerClick = this._handleSelectedItemsContainerClick.bind(this);
}
_getItemById(id) {
return this._options.find((opt) => opt.id === id);
}
// --- Public Methods ---
get placeholder() {
return this._placeholder;
}
set placeholder(value) {
this._placeholder = value;
if (this.inputElement) this.inputElement.placeholder = this._placeholder;
this.setAttribute("placeholder", value);
}
get showCreateButton() {
return this._showCreateButton;
}
set showCreateButton(value) {
const boolValue = String(value).toLowerCase() !== "false" && value !== false;
if (this._showCreateButton === boolValue) return;
this._showCreateButton = boolValue;
if (this.createNewButton)
this.createNewButton.classList.toggle("hidden", !this._showCreateButton);
this.setAttribute("show-create-button", this._showCreateButton ? "true" : "false");
}
setOptions(newOptions) {
if (
Array.isArray(newOptions) &&
@@ -82,36 +125,38 @@ export class MultiSelectSimple extends HTMLElement {
) {
this._options = [...newOptions];
const validValues = this._value.filter((id) => this._getItemById(id));
if (validValues.length !== this._value.length) {
this.value = validValues;
}
if (validValues.length !== this._value.length) this.value = validValues;
else if (this.selectedItemsContainer) this._renderSelectedItems();
this._filteredOptions = [];
this._highlightedIndex = -1;
if (this.inputElement && this.inputElement.value) {
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.");
}
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) {
const oldValString = JSON.stringify(this._value.sort());
if (Array.isArray(newVal)) {
this._value = [
...new Set(newVal.filter((id) => typeof id === "string" && this._getItemById(id))),
];
} else {
this._value = [];
} else if (typeof newVal === "string" && newVal.trim() !== "") {
const singleId = newVal.trim();
if (this._getItemById(singleId) && !this._value.includes(singleId)) this._value = [singleId];
else if (!this._getItemById(singleId))
this._value = this._value.filter((id) => id !== singleId);
} else this._value = [];
const newValString = JSON.stringify(this._value.sort());
if (oldValString !== newValString) {
this._updateFormValue();
if (this.selectedItemsContainer) this._renderSelectedItems();
this._updateRootElementStateClasses();
this.dispatchEvent(new Event("change", { bubbles: true }));
}
this._updateFormValue();
if (this.selectedItemsContainer) this._renderSelectedItems();
this._updateRootElementStateClasses();
}
get name() {
@@ -132,6 +177,9 @@ export class MultiSelectSimple extends HTMLElement {
this.selectedItemsContainer = this.querySelector(`.${MSS_SELECTED_ITEMS_CONTAINER_CLASS}`);
this.hiddenSelect = this.querySelector(`.${MSS_HIDDEN_SELECT_CLASS}`);
this.placeholder = this.getAttribute("placeholder") || "Search items...";
this.showCreateButton = this.getAttribute("show-create-button") !== "false";
if (this.name && this.hiddenSelect) this.hiddenSelect.name = this.name;
this.inputElement.addEventListener("input", this._handleInput);
@@ -141,16 +189,15 @@ export class MultiSelectSimple extends HTMLElement {
this.optionsListElement.addEventListener("mousedown", this._handleOptionMouseDown);
this.optionsListElement.addEventListener("click", this._handleOptionClick);
this.createNewButton.addEventListener("click", this._handleCreateNewButtonClick);
this.selectedItemsContainer.addEventListener("click", this._handleSelectedItemsContainerClick);
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")) {
const attrValue = this.getAttribute("value");
try {
this.value = JSON.parse(this.getAttribute("value"));
this.value = JSON.parse(attrValue);
} catch (e) {
this.value = this.getAttribute("value")
this.value = attrValue
.split(",")
.map((s) => s.trim())
.filter(Boolean);
@@ -173,12 +220,36 @@ export class MultiSelectSimple extends HTMLElement {
this.optionsListElement.removeEventListener("mousedown", this._handleOptionMouseDown);
this.optionsListElement.removeEventListener("click", this._handleOptionClick);
}
if (this.createNewButton) {
if (this.createNewButton)
this.createNewButton.removeEventListener("click", this._handleCreateNewButtonClick);
}
if (this.selectedItemsContainer)
this.selectedItemsContainer.removeEventListener(
"click",
this._handleSelectedItemsContainerClick,
);
clearTimeout(this._blurTimeout);
}
static get observedAttributes() {
return ["disabled", "name", "value", "placeholder", "show-create-button"];
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return;
if (name === "disabled") this.disabledCallback(this.hasAttribute("disabled"));
else if (name === "name" && this.hiddenSelect) this.hiddenSelect.name = newValue;
else if (name === "value" && this.inputElement) {
try {
this.value = JSON.parse(newValue);
} catch (e) {
this.value = newValue
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
} else if (name === "placeholder") this.placeholder = newValue;
else if (name === "show-create-button") this.showCreateButton = newValue;
}
formAssociatedCallback(form) {}
formDisabledCallback(disabled) {
this.disabledCallback(disabled);
@@ -187,8 +258,10 @@ export class MultiSelectSimple extends HTMLElement {
this.value = [];
this._hideOptionsList();
if (this.inputElement) this.inputElement.value = "";
// Create button visibility is not tied to reset, it's always shown
this.placeholder = this.getAttribute("placeholder") || "Search items...";
this.showCreateButton = this.getAttribute("show-create-button") !== "false";
this._updateRootElementStateClasses();
this._renderSelectedItems();
}
formStateRestoreCallback(state, mode) {
this.value = Array.isArray(state) ? state : [];
@@ -201,71 +274,72 @@ export class MultiSelectSimple extends HTMLElement {
this._value.forEach((id) => {
const option = document.createElement("option");
option.value = id;
option.textContent = id;
const itemData = this._getItemById(id);
option.textContent = itemData ? itemData.name : 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.toggleAttribute("disabled", disabled);
this.querySelectorAll(`.${MSS_SELECTED_ITEM_DELETE_BTN_CLASS}`).forEach(
(btn) => (btn.disabled = disabled),
);
if (this.hiddenSelect) this.hiddenSelect.disabled = disabled;
if (disabled) this._hideOptionsList();
}
_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>
<style>
.${MSS_HIDDEN_SELECT_CLASS} { display: block !important; visibility: hidden !important; position: absolute !important; width: 0px !important; height: 0px !important; opacity: 0 !important; pointer-events: none !important; margin: -1px !important; padding: 0 !important; border: 0 !important; overflow: hidden !important; clip: rect(0, 0, 0, 0) !important; white-space: nowrap !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_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_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..."/>
<input type="text"
class="${MSS_TEXT_INPUT_CLASS} w-full outline-none bg-transparent text-sm"
placeholder="${this.placeholder}"
aria-autocomplete="list"
aria-expanded="${this._isOptionsListVisible}"
aria-controls="options-list-${componentId}"
autocomplete="off" autocorrect="off" autocapitalize="none" spellcheck="false" role="combobox" />
</div>
<button type="button" class="${MSS_CREATE_NEW_BUTTON_CLASS} px-2 py-1 text-xs rounded-sm"> +
</button>
<button type="button" class="${MSS_CREATE_NEW_BUTTON_CLASS} ${!this.showCreateButton ? "hidden" : ""}" title="Create new item from input">+</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>
<ul id="options-list-${componentId}" 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") || "mss_default_name"}" 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"]');
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) {
const detailSpan = document.createElement("span");
detailSpan.className = "ml-1 opacity-75 text-xs";
detailSpan.textContent = `(${itemData.additional_data})`;
textEl.appendChild(detailSpan);
detailEl.textContent = `(${itemData.additional_data})`;
detailEl.classList.remove("hidden"); // Toggle visibility via JS
} else {
detailEl.classList.add("hidden"); // Toggle visibility via JS
}
const deleteBtn = pillEl.querySelector('[data-ref="deleteBtn"]');
deleteBtn.setAttribute("aria-label", `Remove ${itemData.name}`);
deleteBtn.dataset.id = itemId;
deleteBtn.disabled = this.hasAttribute("disabled");
@@ -275,20 +349,19 @@ export class MultiSelectSimple extends HTMLElement {
});
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.selectedItemsContainer.innerHTML = `<span class="${MSS_NO_ITEMS_TEXT_CLASS}">No items selected</span>`;
} else {
this._value.forEach((id) => {
const pillEl = this._createSelectedItemElement(id);
if (pillEl) this.selectedItemsContainer.appendChild(pillEl);
});
}
this._updateRootElementStateClasses();
}
_createOptionElement(itemData, index) {
const fragment = this.optionTemplate.content.cloneNode(true);
const li = fragment.firstElementChild;
@@ -298,43 +371,61 @@ export class MultiSelectSimple extends HTMLElement {
detailEl.textContent = itemData.additional_data ? `(${itemData.additional_data})` : "";
li.dataset.id = itemData.id;
li.setAttribute("aria-selected", String(index === this._highlightedIndex));
const optionElementId = `option-${this.id || "mss"}-${itemData.id}`;
li.id = optionElementId;
if (index === this._highlightedIndex) {
li.classList.add(MSS_HIGHLIGHTED_OPTION_CLASS);
li.id = `highlighted-option-${this.id || "multi-select-simple"}`;
if (this.inputElement)
this.inputElement.setAttribute("aria-activedescendant", optionElementId);
}
return li;
}
_renderOptionsList() {
if (!this.optionsListElement) return;
if (!this.optionsListElement || !this.inputElement) return;
this.optionsListElement.innerHTML = "";
this.inputElement.removeAttribute("aria-activedescendant");
if (this._filteredOptions.length === 0 || !this._isOptionsListVisible) {
this.optionsListElement.classList.add("hidden");
this.inputElement.setAttribute("aria-expanded", "false");
} else {
this.optionsListElement.classList.remove("hidden");
this.inputElement.setAttribute("aria-expanded", "true");
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"}`,
`.${MSS_HIGHLIGHTED_OPTION_CLASS}`,
);
if (highlightedElement) highlightedElement.scrollIntoView({ block: "nearest" });
if (highlightedElement) {
highlightedElement.scrollIntoView({ block: "nearest" });
this.inputElement.setAttribute("aria-activedescendant", highlightedElement.id);
}
}
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.
_handleSelectedItemsContainerClick(event) {
if (
event.target === this.selectedItemsContainer &&
this.inputElement &&
!this.inputElement.disabled
) {
this.inputElement.focus();
}
}
_handleCreateNewButtonClick() {
if (this.hasAttribute("disabled") || !this.showCreateButton) return;
const inputValue = this.inputElement ? this.inputElement.value.trim() : "";
this.dispatchEvent(
new CustomEvent("createnew", {
detail: { value: inputValue },
bubbles: true,
composed: true,
}),
);
}
_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;
@@ -342,24 +433,33 @@ export class MultiSelectSimple extends HTMLElement {
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))
);
const nameMatch = item.name.toLowerCase().includes(searchTermLower);
const additionalDataMatch =
item.additional_data && item.additional_data.toLowerCase().includes(searchTermLower);
return nameMatch || additionalDataMatch;
});
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;
}
// Removed: Backspace on empty input to delete last item
// if (event.key === "Backspace" && this.inputElement.value === "" && this._value.length > 0) {
// event.preventDefault();
// const lastItemId = this._value[this._value.length - 1];
// this._handleDeleteSelectedItem(lastItemId);
// return;
// }
if (!this._isOptionsListVisible || this._filteredOptions.length === 0) {
if (event.key === "Escape") this._hideOptionsList();
if (
(event.key === "ArrowDown" || event.key === "ArrowUp") &&
this.inputElement.value.length > 0
) {
this._handleInput({ target: this.inputElement });
}
return;
}
switch (event.key) {
@@ -390,128 +490,42 @@ export class MultiSelectSimple extends HTMLElement {
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();
}
if (this.inputWrapper) this.inputWrapper.classList.add(MSS_INPUT_WRAPPER_FOCUSED_CLASS);
if (this.inputElement.value.length > 0) this._handleInput({ target: this.inputElement });
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",
);
if (this.inputWrapper) this.inputWrapper.classList.remove(MSS_INPUT_WRAPPER_FOCUSED_CLASS);
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();
}
}
if (!this.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);
}
if (li && li.dataset.id) this._selectItem(li.dataset.id);
}
_selectItem(itemId) {
if (itemId && !this._value.includes(itemId)) {
this._value.push(itemId);
this._updateFormValue();
this._renderSelectedItems();
}
if (itemId && !this._value.includes(itemId)) this.value = [...this._value, itemId];
if (this.inputElement) this.inputElement.value = "";
this._filteredOptions = [];
this._hideOptionsList();
// Create button remains visible
if (this.inputElement) this.inputElement.focus();
if (this.inputElement && !this.hasAttribute("disabled")) this.inputElement.focus();
}
_handleDeleteSelectedItem(itemId) {
this._value = this._value.filter((id) => id !== itemId);
this._updateFormValue();
this._renderSelectedItems();
if (this.inputElement) {
this.inputElement.focus();
this.value = this._value.filter((id) => id !== itemId);
if (this.inputElement && this.inputElement.value)
this._handleInput({ target: this.inputElement });
}
if (this.inputElement && !this.hasAttribute("disabled")) this.inputElement.focus();
}
}
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);
});

View File

@@ -575,4 +575,240 @@
transform: rotate(360deg);
} /* Pause at the final position */
}
/* Multi-Select-Role example styles */
.msr-selected-items-container {
@apply rounded-md;
}
.msr-placeholder-no-selection-text {
@apply text-sm text-gray-500 italic px-2 py-1;
}
.msr-input-area-wrapper {
@apply p-2 rounded-md;
}
.msr-input-area-wrapper.msr-input-area-default-border {
@apply border border-gray-300;
}
.msr-input-area-wrapper.msr-input-area-default-border:focus-within {
@apply focus-within:border-gray-500 focus-within:ring-1 focus-within:ring-gray-400;
}
.msr-input-area-wrapper.msr-input-area-staged {
@apply border border-transparent;
}
.msr-text-input {
@apply bg-transparent text-sm placeholder-gray-400;
}
.msr-selected-item-pill {
@apply bg-gray-200 text-gray-700 px-3 py-[0.3rem] rounded-md text-sm inline-flex items-center m-0.5;
}
.msr-item-name {
@apply font-medium;
}
.msr-item-additional-data {
@apply text-xs ml-1 text-gray-600;
}
.msr-selected-item-role {
@apply font-semibold text-xs ml-1 text-gray-800;
}
.msr-selected-item-delete-btn {
@apply bg-transparent border-none text-gray-500 text-lg leading-none px-1 cursor-pointer opacity-60 transition-opacity duration-200;
}
.msr-selected-item-delete-btn:hover {
@apply hover:opacity-100 hover:text-gray-900;
}
.msr-staged-item-pill {
@apply bg-gray-100 text-gray-800 px-2 py-1 rounded-md text-sm font-medium;
}
.msr-staged-item-text {
@apply mr-2;
}
.msr-staged-role-select {
@apply px-2 py-1 text-sm rounded-md border border-gray-300 bg-white outline-none text-gray-700;
}
.msr-staged-role-select:focus {
@apply focus:border-gray-500 focus:ring-1 focus:ring-gray-400;
}
.msr-staged-cancel-btn {
@apply w-5 h-5 bg-gray-200 text-gray-600 rounded-full text-sm leading-none cursor-pointer;
}
.msr-staged-cancel-btn:hover {
@apply hover:bg-gray-300 hover:text-gray-800;
}
.msr-pre-add-button {
@apply w-10 h-[42px] text-xl rounded-md bg-gray-50 text-gray-700 border border-gray-300 font-semibold outline-none;
}
.msr-pre-add-button:focus {
@apply focus:border-gray-500 focus:ring-1 focus:ring-gray-400;
}
.msr-pre-add-button:hover {
@apply hover:bg-gray-100;
}
.msr-pre-add-button:disabled {
@apply disabled:bg-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed disabled:border-gray-200;
}
.msr-pre-add-button.hidden {
@apply hidden;
}
.msr-add-button {
@apply px-4 py-2 text-sm rounded-md bg-gray-600 text-white font-medium;
}
.msr-add-button:hover {
@apply hover:bg-gray-700;
}
.msr-add-button:disabled {
@apply disabled:bg-gray-300 disabled:cursor-not-allowed;
}
.msr-add-button.hidden {
@apply hidden;
}
.msr-options-list {
@apply bg-white border border-gray-300 rounded-md shadow-md;
}
.msr-options-list.hidden {
@apply hidden;
}
.msr-option-item {
@apply px-3 py-2 text-sm cursor-pointer transition-colors duration-75;
}
.msr-option-item:hover {
@apply bg-gray-100 text-gray-800;
}
.msr-option-item-highlighted {
@apply bg-gray-100 text-gray-800;
}
.msr-option-item-name {
@apply font-medium;
}
.msr-option-item-detail {
@apply text-xs ml-2 text-gray-500;
}
.msr-option-item-highlighted .msr-option-item-detail,
.msr-option-item:hover .msr-option-item-detail {
/* Ensure detail text color changes on hover too */
@apply text-gray-600;
}
multi-select-role[disabled] {
/* This remains standard CSS as Tailwind's disabled: variant is for native elements */
opacity: 0.6;
cursor: not-allowed;
}
.msr-hidden-select {
/* No specific styling needed as it's visually hidden by JS/inline style */
}
/* --- MultiSelectSimple Component Base Styles (using @apply) --- */
.mss-component-wrapper {
/* 'relative' is set inline for positioning dropdown */
}
.mss-selected-items-container {
@apply border border-gray-300 p-1.5 rounded;
/* Tailwind classes from component: flex flex-wrap gap-1 mb-1 min-h-[38px] */
}
.mss-no-items-text {
@apply italic text-xs text-gray-500 p-1 w-full; /* Adjusted font size slightly to match 'xs' */
}
.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 */
/* Tailwind classes from component: flex items-center */
}
.mss-selected-item-text {
/* Base styles for text part of the pill */
}
.mss-selected-item-pill-detail {
@apply ml-1 opacity-75 text-xs text-gray-600;
}
.mss-selected-item-pill-detail.hidden {
@apply hidden;
}
.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;
}
.mss-input-controls-container {
/* Tailwind classes from component: flex items-center space-x-2 */
}
.mss-input-wrapper {
@apply border border-gray-300 rounded;
/* Tailwind classes from component: relative flex items-center flex-grow */
}
.mss-input-wrapper-focused {
@apply border-indigo-600 ring-1 ring-indigo-600; /* Using ring for focus shadow */
}
.mss-text-input {
@apply py-1.5 px-2 text-sm;
/* Tailwind classes from component: w-full outline-none bg-transparent */
}
.mss-text-input::placeholder {
@apply text-gray-400 italic;
}
.mss-create-new-button {
@apply bg-gray-100 text-gray-700 border border-gray-300 py-1 px-1.5 text-sm rounded hover:bg-gray-200 hover:border-gray-400 disabled:bg-gray-50 disabled:text-gray-400 disabled:border-gray-200 disabled:opacity-70 disabled:cursor-not-allowed;
}
.mss-create-new-button.hidden {
@apply hidden !important; /* Ensure it hides */
}
.mss-options-list {
@apply bg-white border border-gray-300 rounded shadow-md; /* 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;
}
.mss-option-item-name {
@apply font-medium;
}
.mss-option-item-detail {
@apply text-gray-500 text-xs ml-1.5;
}
.mss-option-item-highlighted {
@apply bg-indigo-100 text-indigo-800;
}
.mss-option-item-highlighted .mss-option-item-name {
/* @apply font-medium; */ /* Already set by .mss-option-item-name, inherit color from parent */
}
.mss-option-item-highlighted .mss-option-item-detail {
@apply text-indigo-700;
}
.mss-hidden-select {
/* Styles are inline in _render for !important, no change needed here */
}
multi-select-simple[disabled] {
@apply opacity-60; /* Adjusted opacity */
}
multi-select-simple[disabled] .mss-selected-items-container {
@apply bg-gray-100;
}
multi-select-simple[disabled] .mss-selected-item-pill {
@apply bg-gray-300 text-gray-500;
}
multi-select-simple[disabled] .mss-selected-item-delete-btn {
@apply text-gray-400;
}
}