Skip to content

Instantly share code, notes, and snippets.

@celsowm
Last active July 30, 2025 02:30
Show Gist options
  • Save celsowm/1eefc7a1e2cddeb70a8743e5002bf1de to your computer and use it in GitHub Desktop.
Save celsowm/1eefc7a1e2cddeb70a8743e5002bf1de to your computer and use it in GitHub Desktop.
AutocompleteTextarea
<!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