(() => {
document.querySelectorAll('[data-mentions]').forEach((input, fieldIndex) => {
const menu = document.createElement('div');
menu.className = 'repo-search-menu mention-menu';
menu.id = `mention-options-${fieldIndex}`;
menu.setAttribute('role', 'listbox');
menu.setAttribute('aria-label', 'Usernames');
menu.hidden = true;
document.body.append(menu);
input.setAttribute('autocomplete', 'off');
input.setAttribute('aria-autocomplete', 'list');
input.setAttribute('aria-controls', menu.id);
input.setAttribute('aria-expanded', 'false');
let results = [];
let activeIndex = -1;
let token = null;
let requestId = 0;
let timer;
let controller;
const currentToken = () => {
const caret = input.selectionStart;
if (caret === null || caret !== input.selectionEnd) return null;
const match = input.value.slice(0, caret).match(/(?:^|[^\w@/\\])@([a-z0-9._-]{0,63})$/i);
if (!match) return null;
return {
query: match[1],
start: caret - match[1].length - 1,
end: caret + (input.value.slice(caret).match(/^[a-z0-9._-]*/i) || [''])[0].length,
};
};
const close = () => {
clearTimeout(timer);
controller?.abort();
requestId += 1;
menu.hidden = true;
results = [];
activeIndex = -1;
input.setAttribute('aria-expanded', 'false');
input.removeAttribute('aria-activedescendant');
};
const position = () => {
const rect = input.getBoundingClientRect();
const below = window.innerHeight - rect.bottom - 12;
const above = rect.top - 12;
const placeAbove = below < 160 && above > below;
const height = Math.max(40, Math.min(256, placeAbove ? above : below));
menu.style.width = `${Math.min(Math.max(rect.width, 240), 360, window.innerWidth - 32)}px`;
menu.style.maxHeight = `${height}px`;
menu.style.left = `${Math.max(16, Math.min(rect.left, window.innerWidth - menu.offsetWidth - 16))}px`;
menu.style.top = `${placeAbove ? Math.max(8, rect.top - menu.offsetHeight - 4) : rect.bottom + 4}px`;
};
const highlight = (index) => {
activeIndex = index;
Array.from(menu.querySelectorAll('[role="option"]')).forEach((option, i) => {
option.classList.toggle('active', i === index);
option.setAttribute('aria-selected', String(i === index));
if (i === index) {
input.setAttribute('aria-activedescendant', option.id);
option.scrollIntoView({ block: 'nearest' });
}
});
};
const choose = (index) => {
const current = currentToken();
const user = results[index];
if (!user || !current || !token || current.start !== token.start || current.query !== token.query) return;
const suffix = input.value.slice(current.end);
const replacement = `@${user.username}${!suffix || !/^[\s.,;:!?)}\]]/.test(suffix) ? ' ' : ''}`;
if (input.maxLength > 0 && input.value.length - (current.end - current.start) + replacement.length > input.maxLength) return;
input.focus();
input.setRangeText(replacement, current.start, current.end, 'end');
close();
input.dispatchEvent(new Event('input', { bubbles: true }));
};
const render = (users, message = 'No users found.') => {
results = users;
menu.replaceChildren();
input.removeAttribute('aria-activedescendant');
activeIndex = -1;
users.forEach((user, index) => {
const option = document.createElement('button');
option.type = 'button';
option.tabIndex = -1;
option.className = 'repo-search-link mention-option';
option.id = `${menu.id}-${index}`;
option.setAttribute('role', 'option');
option.setAttribute('aria-selected', 'false');
const username = document.createElement('strong');
username.textContent = `@${user.username}`;
option.append(username);
if (user.display_name) {
const name = document.createElement('small');
name.textContent = user.display_name;
option.append(name);
}
option.addEventListener('mousedown', (event) => event.preventDefault());
option.addEventListener('click', () => choose(index));
menu.append(option);
});
if (!users.length) {
const status = document.createElement('div');
status.className = 'ref-picker-empty';
status.setAttribute('role', 'status');
status.textContent = message;
menu.append(status);
}
menu.hidden = false;
input.setAttribute('aria-expanded', 'true');
position();
if (users.length) highlight(0);
};
const search = () => {
close();
token = currentToken();
if (!token || document.activeElement !== input) return;
const nextToken = token;
const id = requestId;
timer = setTimeout(async () => {
controller = new AbortController();
try {
const url = new URL('/-/users/search', window.location.origin);
url.searchParams.set('q', nextToken.query);
const response = await fetch(url, { signal: controller.signal, headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error('Username search failed');
const data = await response.json();
const current = currentToken();
if (id !== requestId || document.activeElement !== input || !current || current.start !== nextToken.start || current.query !== nextToken.query) return;
render(data.results || []);
} catch (error) {
if (error.name !== 'AbortError' && id === requestId) render([], 'Unable to load usernames.');
}
}, 120);
};
input.addEventListener('input', (event) => { if (!event.isComposing) search(); else close(); });
input.addEventListener('compositionend', search);
input.addEventListener('click', search);
input.addEventListener('keyup', (event) => { if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) search(); });
input.addEventListener('keydown', (event) => {
if (menu.hidden || event.isComposing) return;
if (event.key === 'Escape') {
event.preventDefault();
close();
} else if (results.length && ['ArrowDown', 'ArrowUp'].includes(event.key)) {
event.preventDefault();
highlight((activeIndex + (event.key === 'ArrowDown' ? 1 : results.length - 1)) % results.length);
} else if (results.length && ['Enter', 'Tab'].includes(event.key)) {
event.preventDefault();
choose(activeIndex);
}
});
input.addEventListener('blur', (event) => { if (!menu.contains(event.relatedTarget)) close(); });
document.addEventListener('pointerdown', (event) => { if (event.target !== input && !menu.contains(event.target)) close(); });
window.addEventListener('resize', () => { if (!menu.hidden) position(); });
window.addEventListener('scroll', () => { if (!menu.hidden) position(); }, { passive: true, capture: true });
});
})();