360 lines
10 KiB
TypeScript
360 lines
10 KiB
TypeScript
// src/utils/clientUtils.ts
|
||
|
||
|
||
export function createToolSlug(toolName: string): string {
|
||
if (!toolName || typeof toolName !== 'string') {
|
||
console.warn('[toolHelpers] Invalid toolName provided to createToolSlug:', toolName);
|
||
return '';
|
||
}
|
||
|
||
return toolName.toLowerCase()
|
||
.replace(/[^a-z0-9\s-]/g, '')
|
||
.replace(/\s+/g, '-')
|
||
.replace(/-+/g, '-')
|
||
.replace(/^-|-$/g, '');
|
||
}
|
||
|
||
export function findToolByIdentifier(tools: any[], identifier: string): any | undefined {
|
||
if (!identifier || !Array.isArray(tools)) return undefined;
|
||
|
||
return tools.find((tool: any) =>
|
||
tool.name === identifier ||
|
||
createToolSlug(tool.name) === identifier.toLowerCase()
|
||
);
|
||
}
|
||
|
||
export function isToolHosted(tool: any): boolean {
|
||
return tool.projectUrl !== undefined &&
|
||
tool.projectUrl !== null &&
|
||
tool.projectUrl !== "" &&
|
||
tool.projectUrl.trim() !== "";
|
||
}
|
||
|
||
interface AutocompleteOptions {
|
||
minLength?: number;
|
||
maxResults?: number;
|
||
placeholder?: string;
|
||
allowMultiple?: boolean;
|
||
separator?: string;
|
||
filterFunction?: (query: string) => any[];
|
||
renderFunction?: (item: any) => string;
|
||
hiddenInput?: HTMLInputElement;
|
||
}
|
||
|
||
export class AutocompleteManager {
|
||
public input: HTMLInputElement;
|
||
public dataSource: any[];
|
||
public options: AutocompleteOptions;
|
||
public isOpen: boolean = false;
|
||
public selectedIndex: number = -1;
|
||
public filteredData: any[] = [];
|
||
public selectedItems: Set<string> = new Set();
|
||
public dropdown!: HTMLElement;
|
||
public selectedContainer!: HTMLElement;
|
||
|
||
constructor(inputElement: HTMLInputElement, dataSource: any[], options: AutocompleteOptions = {}) {
|
||
this.input = inputElement;
|
||
this.dataSource = dataSource;
|
||
this.options = {
|
||
minLength: 1,
|
||
maxResults: 10,
|
||
placeholder: 'Type to search...',
|
||
allowMultiple: false,
|
||
separator: ', ',
|
||
filterFunction: this.defaultFilter.bind(this),
|
||
renderFunction: this.defaultRender.bind(this),
|
||
...options
|
||
};
|
||
|
||
this.init();
|
||
}
|
||
|
||
init(): void {
|
||
this.createDropdown();
|
||
this.bindEvents();
|
||
|
||
if (this.options.allowMultiple) {
|
||
this.initMultipleMode();
|
||
}
|
||
}
|
||
|
||
createDropdown(): void {
|
||
this.dropdown = document.createElement('div');
|
||
this.dropdown.className = 'autocomplete-dropdown';
|
||
this.dropdown.style.cssText = `
|
||
position: absolute;
|
||
top: 100%;
|
||
left: 0;
|
||
right: 0;
|
||
background: var(--color-bg);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 0.375rem;
|
||
box-shadow: var(--shadow-lg);
|
||
max-height: 200px;
|
||
overflow-y: auto;
|
||
z-index: 1000;
|
||
display: none;
|
||
`;
|
||
|
||
const parentElement = this.input.parentNode as HTMLElement;
|
||
parentElement.style.position = 'relative';
|
||
parentElement.insertBefore(this.dropdown, this.input.nextSibling);
|
||
}
|
||
|
||
bindEvents(): void {
|
||
this.input.addEventListener('input', (e) => {
|
||
this.handleInput((e.target as HTMLInputElement).value);
|
||
});
|
||
|
||
this.input.addEventListener('keydown', (e) => {
|
||
this.handleKeydown(e);
|
||
});
|
||
|
||
this.input.addEventListener('focus', () => {
|
||
if (this.input.value.length >= (this.options.minLength || 1)) {
|
||
this.showDropdown();
|
||
}
|
||
});
|
||
|
||
this.input.addEventListener('blur', () => {
|
||
setTimeout(() => {
|
||
const activeElement = document.activeElement;
|
||
if (!activeElement || !this.dropdown.contains(activeElement)) {
|
||
this.hideDropdown();
|
||
}
|
||
}, 150);
|
||
});
|
||
|
||
document.addEventListener('click', (e) => {
|
||
const target = e.target as Node;
|
||
if (!this.input.contains(target) && !this.dropdown.contains(target)) {
|
||
this.hideDropdown();
|
||
}
|
||
});
|
||
}
|
||
|
||
initMultipleMode(): void {
|
||
this.selectedContainer = document.createElement('div');
|
||
this.selectedContainer.className = 'autocomplete-selected';
|
||
this.selectedContainer.style.cssText = `
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.5rem;
|
||
margin-bottom: 0.5rem;
|
||
min-height: 1.5rem;
|
||
`;
|
||
|
||
const parentElement = this.input.parentNode as HTMLElement;
|
||
parentElement.insertBefore(this.selectedContainer, this.input);
|
||
this.updateSelectedDisplay();
|
||
}
|
||
|
||
handleInput(value: string): void {
|
||
if (value.length >= (this.options.minLength || 1)) {
|
||
this.filteredData = this.options.filterFunction!(value);
|
||
this.selectedIndex = -1;
|
||
this.renderDropdown();
|
||
this.showDropdown();
|
||
} else {
|
||
this.hideDropdown();
|
||
}
|
||
}
|
||
|
||
handleKeydown(e: KeyboardEvent): void {
|
||
if (!this.isOpen) return;
|
||
|
||
switch (e.key) {
|
||
case 'ArrowDown':
|
||
e.preventDefault();
|
||
this.selectedIndex = Math.min(this.selectedIndex + 1, this.filteredData.length - 1);
|
||
this.updateHighlight();
|
||
break;
|
||
|
||
case 'ArrowUp':
|
||
e.preventDefault();
|
||
this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
|
||
this.updateHighlight();
|
||
break;
|
||
|
||
case 'Enter':
|
||
e.preventDefault();
|
||
if (this.selectedIndex >= 0) {
|
||
this.selectItem(this.filteredData[this.selectedIndex]);
|
||
}
|
||
break;
|
||
|
||
case 'Escape':
|
||
this.hideDropdown();
|
||
break;
|
||
}
|
||
}
|
||
|
||
defaultFilter(query: string): any[] {
|
||
const searchTerm = query.toLowerCase();
|
||
return this.dataSource
|
||
.filter(item => {
|
||
const text = typeof item === 'string' ? item : item.name || item.label || item.toString();
|
||
return text.toLowerCase().includes(searchTerm) &&
|
||
(!this.options.allowMultiple || !this.selectedItems.has(text));
|
||
})
|
||
.slice(0, this.options.maxResults || 10);
|
||
}
|
||
|
||
defaultRender(item: any): string {
|
||
const text = typeof item === 'string' ? item : item.name || item.label || item.toString();
|
||
return `<div class="autocomplete-item">${this.escapeHtml(text)}</div>`;
|
||
}
|
||
|
||
renderDropdown(): void {
|
||
if (this.filteredData.length === 0) {
|
||
this.dropdown.innerHTML = '<div class="autocomplete-no-results">No results found</div>';
|
||
return;
|
||
}
|
||
|
||
this.dropdown.innerHTML = this.filteredData
|
||
.map((item, index) => {
|
||
const content = this.options.renderFunction!(item);
|
||
return `<div class="autocomplete-option" data-index="${index}" style="
|
||
padding: 0.5rem;
|
||
cursor: pointer;
|
||
border-bottom: 1px solid var(--color-border-light);
|
||
transition: background-color 0.15s ease;
|
||
">${content}</div>`;
|
||
})
|
||
.join('');
|
||
|
||
this.dropdown.querySelectorAll('.autocomplete-option').forEach((option, index) => {
|
||
option.addEventListener('click', () => {
|
||
this.selectItem(this.filteredData[index]);
|
||
});
|
||
|
||
option.addEventListener('mouseenter', () => {
|
||
this.selectedIndex = index;
|
||
this.updateHighlight();
|
||
});
|
||
});
|
||
}
|
||
|
||
updateHighlight(): void {
|
||
this.dropdown.querySelectorAll('.autocomplete-option').forEach((option, index) => {
|
||
(option as HTMLElement).style.backgroundColor = index === this.selectedIndex
|
||
? 'var(--color-bg-secondary)'
|
||
: 'transparent';
|
||
});
|
||
}
|
||
|
||
selectItem(item: any): void {
|
||
const text = typeof item === 'string' ? item : item.name || item.label || item.toString();
|
||
|
||
if (this.options.allowMultiple) {
|
||
this.selectedItems.add(text);
|
||
this.updateSelectedDisplay();
|
||
this.updateInputValue();
|
||
this.input.value = '';
|
||
} else {
|
||
this.input.value = text;
|
||
this.hideDropdown();
|
||
}
|
||
|
||
this.input.dispatchEvent(new CustomEvent('autocomplete:select', {
|
||
detail: { item, text, selectedItems: Array.from(this.selectedItems) }
|
||
}));
|
||
}
|
||
|
||
removeItem(text: string): void {
|
||
if (this.options.allowMultiple) {
|
||
this.selectedItems.delete(text);
|
||
this.updateSelectedDisplay();
|
||
this.updateInputValue();
|
||
}
|
||
}
|
||
|
||
updateSelectedDisplay(): void {
|
||
if (!this.options.allowMultiple || !this.selectedContainer) return;
|
||
|
||
this.selectedContainer.innerHTML = Array.from(this.selectedItems)
|
||
.map(item => `
|
||
<span class="autocomplete-tag" style="
|
||
background-color: var(--color-primary);
|
||
color: white;
|
||
padding: 0.25rem 0.5rem;
|
||
border-radius: 0.25rem;
|
||
font-size: 0.875rem;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.25rem;
|
||
">
|
||
${this.escapeHtml(item)}
|
||
<button type="button" class="autocomplete-remove" data-item="${this.escapeHtml(item)}" style="
|
||
background: none;
|
||
border: none;
|
||
color: white;
|
||
font-weight: bold;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
width: 1rem;
|
||
height: 1rem;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
">×</button>
|
||
</span>
|
||
`)
|
||
.join('');
|
||
|
||
this.selectedContainer.querySelectorAll('.autocomplete-remove').forEach(btn => {
|
||
btn.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
this.removeItem((btn as HTMLElement).getAttribute('data-item')!);
|
||
});
|
||
});
|
||
}
|
||
|
||
updateInputValue(): void {
|
||
if (this.options.allowMultiple && this.options.hiddenInput) {
|
||
this.options.hiddenInput.value = Array.from(this.selectedItems).join(this.options.separator || ', ');
|
||
}
|
||
}
|
||
|
||
showDropdown(): void {
|
||
this.dropdown.style.display = 'block';
|
||
this.isOpen = true;
|
||
}
|
||
|
||
hideDropdown(): void {
|
||
this.dropdown.style.display = 'none';
|
||
this.isOpen = false;
|
||
this.selectedIndex = -1;
|
||
}
|
||
|
||
escapeHtml(text: string): string {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
setDataSource(newDataSource: any[]): void {
|
||
this.dataSource = newDataSource;
|
||
}
|
||
|
||
getSelectedItems(): string[] {
|
||
return Array.from(this.selectedItems);
|
||
}
|
||
|
||
setSelectedItems(items: string[]): void {
|
||
this.selectedItems = new Set(items);
|
||
if (this.options.allowMultiple) {
|
||
this.updateSelectedDisplay();
|
||
this.updateInputValue();
|
||
}
|
||
}
|
||
|
||
destroy(): void {
|
||
if (this.dropdown && this.dropdown.parentNode) {
|
||
this.dropdown.parentNode.removeChild(this.dropdown);
|
||
}
|
||
if (this.selectedContainer && this.selectedContainer.parentNode) {
|
||
this.selectedContainer.parentNode.removeChild(this.selectedContainer);
|
||
}
|
||
}
|
||
} |