Files
JaredTherriault-ComfyUI-JNodes/web/js/common/SearchableDropDown.js
T

571 lines
17 KiB
JavaScript

import { $el } from "/scripts/ui.js";
import { utilitiesInstance } from "./Utilities.js";
/**
* A class representing a searchable dropdown UI component.
*/
export class SearchableDropDown {
constructor() {
// Initialize instance variables
this._container = null;
this._mainButton = null;
this._buttonText = null;
this._dropDownContent = null;
this._searchInput = null;
this._clearSearchButton = null;
this._labelPanel = null;
this._selectedOption = "";
this._bIsContentShown = false;
this._lastScrollAmount = 0;
this._availableOptions = [];
this._keyboardIndex = -1;
// Events
this._ON_SELECT_EVENT_NAME = "selectoption";
}
/**
* Creates the searchable dropdown structure and adds it to the DOM.
*/
createSearchableDropDown() {
this._container = $el("div", {
className: "dropdown",
});
this._container.data = this;
// Dropdown button setup
this._mainButton = $el("button", {
className: "dropbtn",
onclick: () => {
this.toggleContent();
}
});
this._container.appendChild(this._mainButton);
this._buttonText = $el("label");
const buttonInnerContainer = $el("div", {
className: "dropbtn-inner"
}, [
this._buttonText,
$el("label", { textContent: "▼" })
]);
this._mainButton.appendChild(buttonInnerContainer);
// Dropdown content setup
this._dropDownContent = $el("div", {
className: "dropdown-content",
style: {
zIndex: utilitiesInstance.getMaxZIndex(this._container) + 1,
}
});
this._container.appendChild(this._dropDownContent);
// Add search input to the dropdown
this._addSearchInput();
this._labelPanel = $el("div", {
className: "option-panel",
}); // Holds the label children
this._dropDownContent.appendChild(this._labelPanel);
// Close when clicking off this widget stack
document.addEventListener('click', (event) => { // For when any widget in this stack loses focus
if (this._bIsContentShown && !this._container.contains(event.target)) {
this.closeContent();
}
});
// Append the dropdown style rules to the document body
this._appendStyleRules();
return this._container;
}
/**
* Adds a new option to the dropdown's available options.
* Execute search separately to see it in the list.
* @param {string} optionName - The display text of the option.
* @param {string} [optionTooltip=""] - Optional tooltip for the option.
* @param {string} [optionValue=""] - Optional value associated with the option.
*/
addOption(optionName, optionValue, optionTooltip = "") {
if (!this._labelPanel) { return; }
const option = document.createElement("label");
option.textContent = optionName;
option.value = optionValue;
option.title = optionTooltip || optionName;
option.addEventListener("click", () => {
const bInvokeCallbacks = true;
this._selectOption(optionName, bInvokeCallbacks);
this.closeContent();
});
this._availableOptions.push(option);
if (this.getOptionElements().length == 1) {
this.setOptionSelected(optionName);
}
}
/**
* Adds a unique option to the dropdown (avoids duplicate entries).
* Execute search separately to see it in the list.
* @param {string} optionName - The display text of the option.
* @param {string} [optionTooltip=""] - Optional tooltip for the option.
* @param {string} [optionValue=""] - Optional value associated with the option.
*/
addOptionUnique(optionName, optionValue, optionTooltip = "") {
if (!this.hasOption(optionName)) {
this.addOption(optionName, optionValue, optionTooltip);
}
}
/**
* Removes an option from the dropdown by its display text.
* Execute search separately to see it removed from the list.
* @param {string} optionName - The display text of the option to remove.
*/
removeOption(optionName) {
const options = this.getOptionElements();
for (let labelChildIndex = options.length - 1; labelChildIndex >= 0; labelChildIndex--) {
if (options[labelChildIndex].textContent === optionName) {
this._availableOptions.splice(options[labelChildIndex], 1);
if (this._selectedOption == optionName) {
this._selectOption("");
}
break;
}
}
}
/**
* Checks if an option with the specified display text exists in the dropdown.
* @param {string} optionName - The display text of the option to check.
* @returns {boolean} True if the option exists, otherwise false.
*/
hasOption(optionName) {
const options = this.getOptionElements();
for (let labelChildIndex = options.length - 1; labelChildIndex >= 0; labelChildIndex--) {
if (options[labelChildIndex].textContent === optionName) {
return true;
}
}
return false;
}
getSelectedOptionName() {
return this._selectedOption;
}
getSelectedOptionValue() {
return this.getOptionValue(this.getSelectedOptionName());
}
getSelectedOptionElement() {
return this.getOptionElement(this.getSelectedOptionName());
}
/**
* Clears all options from the dropdown.
* Execute search separately to see it removed from the list.
*/
clearOptions() {
this._availableOptions = [];
this._selectOption("");
}
getOptionNames() {
let outNames = [];
const options = this.getOptionElements();
for (let labelChildIndex = 0; labelChildIndex < options.length; labelChildIndex++) {
outNames.push(options[labelChildIndex].textContent);
}
return outNames;
}
getOptionElements() {
return this._availableOptions;
}
getOptionValue(optionName) {
const option = this.getOptionElement(optionName);
return option ? option.value : "";
}
getOptionElement(optionName) {
const options = this.getOptionElements();
for (let labelChildIndex = options.length - 1; labelChildIndex >= 0; labelChildIndex--) {
if (options[labelChildIndex].textContent === optionName) {
return options[labelChildIndex];
}
}
}
setOptionSelected(optionName, bInvokeCallbacks = false) {
this._selectOption(optionName, bInvokeCallbacks);
}
getFilterText() {
return this._searchInput ? this._searchInput.value : "";
}
setFilterText(inText) {
if (this._searchInput) {
this._searchInput.value = inText;
}
}
setFilterTextAndExecuteSearch(inText) {
if (this._searchInput) {
this.setFilterText(inText);
this.filterOptions(inText);
}
}
clearFilterText() {
if (this._searchInput) {
this.setFilterTextAndExecuteSearch("");
}
}
/**
* Filters the dropdown options based on the input text.
* @param {string} inFilterText - The text to check against the option names
*/
filterOptions(inFilterText) {
const filterText = inFilterText.toUpperCase();
const options = this.getOptionElements();
this._keyboardIndex = -1;
let fragment = document.createDocumentFragment();
for (let i = 0; i < options.length; i++) {
const txtValue = options[i].textContent;
if (!filterText || txtValue.toUpperCase().includes(filterText)) {
fragment.appendChild(options[i]);
}
}
this._labelPanel.replaceChildren(fragment);
}
toggleContent() {
if (this._bIsContentShown) {
this.closeContent();
} else {
this.openContent();
}
}
openContent() {
this._bIsContentShown = true;
this._keyboardIndex = -1;
// Get button and dropdown content positions
const buttonRect = this._mainButton.getBoundingClientRect();
const viewportHeight = window.innerHeight;
if (buttonRect.bottom > (viewportHeight / 2)) {
// console.log(`Open upward: buttonRect,bottom: ${buttonRect.bottom}, viewportHeight: ${viewportHeight}, buttonRect.top: ${buttonRect.top}`);
this._dropDownContent.style.bottom = `${buttonRect.height}px`;
this._dropDownContent.style.top = "auto";
} else {
// console.log(`Open downward: buttonRect,bottom: ${buttonRect.bottom}, viewportHeight: ${viewportHeight}, buttonRect.top: ${buttonRect.top}`);
this._dropDownContent.style.top = `${buttonRect.height}px`;
this._dropDownContent.style.bottom = "auto";
}
this._dropDownContent.classList.add("dropdown-show");
this.filterOptions(this.getFilterText());
this._searchInput.focus();
this._searchInput.select();
// Restore scroll amount two frames later
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.setScrollAmount(this._lastScrollAmount);
})
});
}
closeContent() {
this._lastScrollAmount = this.getCurrentScrollAmount();
this._bIsContentShown = false;
// Remove keyboard highlight from any option
const visibleOptions = this._getVisibleOptions();
for (const option of visibleOptions) {
option.classList.remove("option-keyboard-highlight");
}
this._keyboardIndex = -1;
this._dropDownContent.classList.remove("dropdown-show");
}
getCurrentScrollAmount() {
return this._labelPanel.scrollTop;
}
getLastScrollAmount() {
return this._lastScrollAmount;
}
setScrollAmount(scrollAmount) {
this._labelPanel.scrollTop = scrollAmount;
this._lastScrollAmount = scrollAmount;
}
/**
* Returns the currently visible option elements in the label panel.
* @returns {HTMLElement[]} Array of visible option elements.
*/
_getVisibleOptions() {
return Array.from(this._labelPanel.children);
}
/**
* Updates the keyboard highlight to the specified index.
* @param {number} newIndex - The index to highlight (-1 for none).
*/
_updateKeyboardHighlight(newIndex) {
const visibleOptions = this._getVisibleOptions();
// Remove previous highlight
if (this._keyboardIndex >= 0 && this._keyboardIndex < visibleOptions.length) {
visibleOptions[this._keyboardIndex].classList.remove("option-keyboard-highlight");
}
this._keyboardIndex = newIndex;
// Add new highlight and scroll into view
if (this._keyboardIndex >= 0 && this._keyboardIndex < visibleOptions.length) {
const highlighted = visibleOptions[this._keyboardIndex];
highlighted.classList.add("option-keyboard-highlight");
highlighted.scrollIntoView({ block: "nearest" });
}
}
/**
* Handles keyboard navigation within the dropdown.
* @param {KeyboardEvent} event - The keyboard event.
*/
_handleKeyboardNavigation(event) {
if (!this._bIsContentShown) { return; }
const visibleOptions = this._getVisibleOptions();
if (visibleOptions.length === 0) { return; }
switch (event.key) {
case "ArrowDown":
event.preventDefault();
if (this._keyboardIndex < visibleOptions.length - 1) {
this._updateKeyboardHighlight(this._keyboardIndex + 1);
} else {
this._updateKeyboardHighlight(0);
}
break;
case "ArrowUp":
event.preventDefault();
if (this._keyboardIndex > 0) {
this._updateKeyboardHighlight(this._keyboardIndex - 1);
} else {
this._updateKeyboardHighlight(visibleOptions.length - 1);
}
break;
case "Enter":
event.preventDefault();
if (this._keyboardIndex >= 0 && this._keyboardIndex < visibleOptions.length) {
const option = visibleOptions[this._keyboardIndex];
const bInvokeCallbacks = true;
this._selectOption(option.textContent, bInvokeCallbacks);
this.closeContent();
}
break;
case "Escape":
event.preventDefault();
this.closeContent();
break;
}
}
_selectOption(optionName, bInvokeCallbacks) {
if (this.hasOption(optionName) && this._selectedOption != optionName) {
this._selectedOption = optionName;
this._buttonText.textContent = optionName;
for (const option of this.getOptionElements()) {
option.classList.remove("option-selected");
}
const optionElement = this.getOptionElement(optionName);
optionElement.classList.add("option-selected");
if (bInvokeCallbacks) {
const event = new Event(this._ON_SELECT_EVENT_NAME);
this._container.dispatchEvent(event);
}
}
}
/**
* Adds a search input field to the dropdown content.
*/
_addSearchInput() {
if (!this._dropDownContent) { return; }
const searchContainer = $el("div", {
className: "search-controls",
style: {
display: "flex",
flexDirection: "row"
}
});
this._dropDownContent.appendChild(searchContainer);
this._searchInput = $el("input", {
placeholder: "Search...",
onkeyup: (e) => {
if (["ArrowDown", "ArrowUp", "Enter", "Escape"].includes(e.key)) {
this._handleKeyboardNavigation(e);
} else {
this._keyboardIndex = -1;
this.filterOptions(this.getFilterText());
}
},
style: {
padding: "4px 16px",
width: "100%"
}
});
searchContainer.appendChild(this._searchInput);
// Dropdown button setup
this._clearSearchButton = $el("button", {
textContent: "X",
onclick: () => {
this.clearFilterText();
this._searchInput.focus();
},
style : {
fontWeight: "bold"
}
});
searchContainer.appendChild(this._clearSearchButton);
}
/**
* Appends CSS style rules for the dropdown to the document body.
*/
_appendStyleRules() {
$el("style", {
textContent: `
.dropdown {
position: relative;
display: block;
}
.dropbtn {
cursor: pointer;
width: 100%;
height: 100%;
padding: 4px;
}
.dropbtn-inner {
width: 100%;
height: 100%;
display: flex;
justify-content: space-between;
align-items: center;
}
.dropdown-content {
display: none;
position: absolute;
background-color: var(--fg-color);
width: 100%;
border-radius: 5px;
z-index: 1;
}
.search-controls {
box-sizing: border-box;
border: none;
border-bottom: 1px solid var(--bg-color);
border-radius: 5px;
}
.search-controls:focus {
outline: 2px solid var(--bg-color);
}
.option-panel {
background-color: var(--fg-color);
overflow: scroll;
width: 100%;
max-height: 60vh;
border-radius: 5px;
}
.option-panel label {
color: black;
padding: 4px 16px;
text-decoration: none;
display: block;
border-radius: 5px;
}
.option-panel label:hover {
background-color: lightgreen;
}
.option-selected {
background-color: lightsteelblue;
}
.option-keyboard-highlight {
background-color: moccasin;
}
.dropdown-show {
display: block;
}
`,
parent: document.body,
});
}
}