Last active
July 30, 2025 02:30
-
-
Save celsowm/1eefc7a1e2cddeb70a8743e5002bf1de to your computer and use it in GitHub Desktop.
AutocompleteTextarea
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="pt-BR"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Autocomplete Aprimorado com Lazy Loading</title> | |
<style> | |
/* Estilos Gerais */ | |
body { | |
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; | |
background-color: #f0f2f5; | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
min-height: 100vh; | |
margin: 0; | |
padding: 20px; | |
box-sizing: border-box; | |
} | |
/* Contêiner Principal */ | |
.container { | |
width: 100%; | |
max-width: 600px; | |
background-color: #fff; | |
padding: 25px; | |
border-radius: 8px; | |
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); | |
} | |
h1 { | |
margin: 0 0 10px 0; | |
font-size: 24px; | |
color: #1c1e21; | |
} | |
p { | |
margin: 0 0 20px 0; | |
color: #606770; | |
line-height: 1.5; | |
} | |
/* Área de Texto */ | |
textarea { | |
width: 100%; | |
padding: 12px; | |
font-size: 16px; | |
border: 1px solid #ccc; | |
border-radius: 6px; | |
resize: vertical; | |
box-sizing: border-box; | |
line-height: 1.6; | |
transition: border-color 0.2s; | |
} | |
textarea:focus { | |
outline: none; | |
border-color: #007bff; | |
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); | |
} | |
/* Lista de Sugestões de Autocomplete */ | |
.autocomplete-suggestions { | |
list-style: none; | |
padding: 5px 0; | |
margin: 0; | |
border: 1px solid #ddd; | |
border-radius: 6px; | |
position: absolute; | |
background-color: #fff; | |
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); | |
z-index: 1000; | |
max-height: 220px; | |
overflow-y: auto; | |
width: auto; | |
min-width: 200px; | |
max-width: 320px; | |
} | |
.autocomplete-suggestions li { | |
padding: 10px 15px; | |
cursor: pointer; | |
transition: background-color 0.2s; | |
white-space: nowrap; | |
} | |
.autocomplete-suggestions li.active, | |
.autocomplete-suggestions li:hover { | |
background-color: #007bff; | |
color: #fff; | |
} | |
/* Estilo para o item de carregamento */ | |
.autocomplete-suggestions li.loading { | |
color: #888; | |
cursor: default; | |
} | |
</style> | |
</head> | |
<body> | |
<div class="container"> | |
<h1>Editor com Autocomplete</h1> | |
<p>Comece a digitar em qualquer lugar da área de texto. Use <strong>@</strong> para buscar usuários ou <strong>#</strong> para buscar tópicos (a lista de tópicos será carregada na primeira vez).</p> | |
<textarea id="editor" rows="10" placeholder="Olá @ana_silva, que tal falarmos sobre #javascript ?"></textarea> | |
</div> | |
<script> | |
/** | |
* Classe AutocompleteTextarea | |
* Cria uma funcionalidade de autocomplete para um elemento <textarea>. | |
* SUPORTA LAZY LOADING: Se 'data' for uma função que retorna uma Promise, | |
* ela será executada apenas uma vez para buscar os dados. | |
*/ | |
class AutocompleteTextarea { | |
constructor(textarea, { trigger, data }) { | |
this.textarea = textarea; | |
this.trigger = trigger; | |
this.dataSource = data; | |
// --- NOVA LÓGICA DE LAZY LOAD --- | |
// Verifica se a fonte de dados é uma função (indicando que precisa ser carregada) | |
this.isDataSourcePromise = typeof this.dataSource === 'function'; | |
this.isLoading = false; // Flag para evitar múltiplas chamadas enquanto carrega | |
this.suggestionsListEl = null; | |
this.active = false; | |
this.currentQuery = ''; | |
this.triggerStartIndex = -1; | |
this.onInput = this.onInput.bind(this); | |
this.onKeyDown = this.onKeyDown.bind(this); | |
this.onSuggestionClick = this.onSuggestionClick.bind(this); | |
this.hideSuggestions = this.hideSuggestions.bind(this); | |
this.textarea.addEventListener('input', this.onInput); | |
this.textarea.addEventListener('keydown', this.onKeyDown); | |
} | |
onInput() { | |
const caretPosition = this.textarea.selectionStart; | |
const text = this.textarea.value; | |
const lastTriggerIndex = text.lastIndexOf(this.trigger, caretPosition - 1); | |
if (lastTriggerIndex !== -1) { | |
const query = text.substring(lastTriggerIndex + 1, caretPosition); | |
// Permite query vazia para acionar a busca inicial | |
if (!/\s/.test(query)) { | |
this.active = true; | |
this.currentQuery = query; | |
this.triggerStartIndex = lastTriggerIndex; | |
this.showSuggestions(); // O método principal que agora contém a lógica de lazy load | |
return; | |
} | |
} | |
this.hideSuggestions(); | |
} | |
onKeyDown(e) { | |
if (!this.active || !this.suggestionsListEl) return; | |
const items = Array.from(this.suggestionsListEl.children); | |
if (items.length === 0 || items[0].classList.contains('loading')) return; // Ignora teclas se estiver carregando | |
let activeIndex = items.findIndex(item => item.classList.contains('active')); | |
switch (e.key) { | |
case 'ArrowDown': | |
case 'ArrowUp': | |
e.preventDefault(); | |
activeIndex = e.key === 'ArrowDown' | |
? (activeIndex + 1) % items.length | |
: (activeIndex - 1 + items.length) % items.length; | |
this.updateActiveSuggestion(items, activeIndex); | |
break; | |
case 'Enter': | |
case 'Tab': | |
if (activeIndex !== -1) { | |
e.preventDefault(); | |
this.selectSuggestion(items[activeIndex].textContent); | |
} | |
break; | |
case 'Escape': | |
e.preventDefault(); | |
this.hideSuggestions(); | |
break; | |
} | |
} | |
updateActiveSuggestion(items, activeIndex) { | |
items.forEach(item => item.classList.remove('active')); | |
items[activeIndex].classList.add('active'); | |
items[activeIndex].scrollIntoView({ block: 'nearest' }); | |
} | |
// --- MÉTODO SHOWSUGGESTIONS MODIFICADO PARA LAZY LOADING --- | |
async showSuggestions() { | |
// 1. Se a fonte de dados é uma função (Promise) e ainda não foi carregada | |
if (this.isDataSourcePromise && !this.isLoading) { | |
this.isLoading = true; | |
// Exibe um indicador de "carregando" para o usuário | |
this.createSuggestionsList(['Carregando...'], true); | |
this.positionSuggestions(); | |
try { | |
// 2. Executa a função para buscar os dados e a substitui pelo resultado | |
const dataArray = await this.dataSource(); | |
this.dataSource = dataArray; // PONTO CRÍTICO: substitui a função pelo array resolvido | |
this.isDataSourcePromise = false; // Marca como carregado | |
} catch (error) { | |
console.error(`Falha ao buscar dados para o gatilho "${this.trigger}":`, error); | |
this.dataSource = []; // Em caso de erro, define como um array vazio | |
this.isDataSourcePromise = false; | |
} finally { | |
this.isLoading = false; | |
// 3. Chama a função novamente para exibir as sugestões com os dados recém-carregados | |
this.showSuggestions(); | |
return; | |
} | |
} | |
// Se estiver carregando, não faz nada até a Promise resolver | |
if (this.isLoading) return; | |
// 4. Lógica original: agora só executa quando a fonte de dados é um array | |
const suggestions = this.dataSource.filter(item => | |
item.toLowerCase().includes(this.currentQuery.toLowerCase()) | |
); | |
if (suggestions.length > 0) { | |
this.createSuggestionsList(suggestions); | |
this.positionSuggestions(); | |
} else { | |
this.hideSuggestions(); | |
} | |
} | |
createSuggestionsList(suggestions, isLoading = false) { | |
if (!this.suggestionsListEl) { | |
this.suggestionsListEl = document.createElement('ul'); | |
this.suggestionsListEl.className = 'autocomplete-suggestions'; | |
document.body.appendChild(this.suggestionsListEl); | |
document.addEventListener('click', this.hideSuggestions, true); | |
} | |
this.suggestionsListEl.innerHTML = ''; | |
suggestions.slice(0, 10).forEach(suggestion => { | |
const li = document.createElement('li'); | |
li.textContent = suggestion; | |
// Adiciona uma classe especial para o item "Carregando..." | |
if (isLoading) { | |
li.classList.add('loading'); | |
} else { | |
li.addEventListener('mousedown', this.onSuggestionClick); | |
} | |
this.suggestionsListEl.appendChild(li); | |
}); | |
} | |
onSuggestionClick(e) { | |
e.preventDefault(); | |
this.selectSuggestion(e.target.textContent); | |
} | |
selectSuggestion(suggestion) { | |
const text = this.textarea.value; | |
const caretPosition = this.textarea.selectionStart; | |
const start = text.substring(0, this.triggerStartIndex); | |
const end = text.substring(caretPosition); | |
const replacement = `${this.trigger}${suggestion} `; | |
this.textarea.value = `${start}${replacement}${end}`; | |
this.hideSuggestions(); | |
this.textarea.focus(); | |
const newCaretPosition = start.length + replacement.length; | |
this.textarea.setSelectionRange(newCaretPosition, newCaretPosition); | |
} | |
hideSuggestions(e) { | |
if (e && (this.textarea.contains(e.target) || this.suggestionsListEl?.contains(e.target))) { | |
return; | |
} | |
if (this.suggestionsListEl) { | |
this.suggestionsListEl.remove(); | |
this.suggestionsListEl = null; | |
document.removeEventListener('click', this.hideSuggestions, true); | |
} | |
this.active = false; | |
} | |
positionSuggestions() { | |
if (!this.suggestionsListEl) return; | |
const coords = this._getCaretCoordinates(); | |
this.suggestionsListEl.style.top = `${coords.top}px`; | |
this.suggestionsListEl.style.left = `${coords.left}px`; | |
} | |
_getCaretCoordinates() { | |
const properties = [ | |
'boxSizing', 'width', 'height', 'overflowX', 'overflowY', | |
'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', | |
'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', | |
'fontStyle', 'fontVariant', 'fontWeight', 'fontStretch', 'fontSize', 'lineHeight', 'fontFamily', | |
'textAlign', 'textTransform', 'textIndent', 'whiteSpace', 'wordWrap', 'wordSpacing', 'letterSpacing' | |
]; | |
const mirrorDiv = document.createElement('div'); | |
document.body.appendChild(mirrorDiv); | |
const style = mirrorDiv.style; | |
const computed = window.getComputedStyle(this.textarea); | |
style.whiteSpace = 'pre-wrap'; | |
style.position = 'absolute'; | |
style.visibility = 'hidden'; | |
properties.forEach(prop => { style[prop] = computed[prop]; }); | |
const textUpToTrigger = this.textarea.value.substring(0, this.triggerStartIndex); | |
mirrorDiv.textContent = textUpToTrigger; | |
const triggerSpan = document.createElement('span'); | |
triggerSpan.textContent = this.textarea.value.substring(this.triggerStartIndex, this.textarea.selectionStart); | |
mirrorDiv.appendChild(triggerSpan); | |
const textareaRect = this.textarea.getBoundingClientRect(); | |
const coords = { | |
top: triggerSpan.offsetTop + parseFloat(computed.lineHeight) + textareaRect.top + window.scrollY, | |
left: triggerSpan.offsetLeft + textareaRect.left + window.scrollX, | |
}; | |
document.body.removeChild(mirrorDiv); | |
return coords; | |
} | |
} | |
// --- INICIALIZAÇÃO --- | |
document.addEventListener('DOMContentLoaded', () => { | |
const editor = document.getElementById('editor'); | |
// Fonte de dados estática (array) | |
const users = ["ana_silva", "bruno_costa", "carlos_santos", "daniela_perez", "eduardo_melo", "fernanda_lima", "gabriel_rocha", "helena_martins", "igor_alves", "julia_ribeiro"]; | |
// --- FONTE DE DADOS LAZY (Promise) --- | |
// Esta função será chamada APENAS UMA VEZ. | |
// Note que ela não precisa mais do parâmetro 'query', pois busca a lista inteira. | |
const fetchTopics = async () => { | |
console.log('Buscando tópicos da "API"... Isso só deve aparecer uma vez.'); | |
// Simula um delay de rede de 1.5 segundos | |
await new Promise(resolve => setTimeout(resolve, 1500)); | |
const allTopics = ["javascript", "ecma6", "frontend", "backend", "webdev", "css", "html", "react", "vue", "angular", "typescript", "nodejs", "deno"]; | |
// A função agora retorna a lista completa. O filtro será aplicado depois. | |
return allTopics; | |
}; | |
// Instancia para @ com dados estáticos | |
new AutocompleteTextarea(editor, { trigger: '@', data: users }); | |
// Instancia para # com dados que serão carregados via Promise (lazy) | |
new AutocompleteTextarea(editor, { trigger: '#', data: fetchTopics }); | |
}); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment