148 lines
5.4 KiB
JavaScript
148 lines
5.4 KiB
JavaScript
(function(root, factory) {
|
|
const api = factory();
|
|
if (typeof module === 'object' && module.exports) module.exports = api;
|
|
else root.createMentionComposer = api.create;
|
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function() {
|
|
'use strict';
|
|
|
|
function activeMention(value, caret) {
|
|
const before = String(value || '').slice(0, Math.max(0, caret));
|
|
const match = before.match(/(^|\s)@([A-Za-z0-9_.-]{2,39})$/);
|
|
if (!match) return null;
|
|
const start = before.length - match[2].length - 1;
|
|
return { start, end:before.length, query:match[2] };
|
|
}
|
|
|
|
function insertMention(value, mention, login) {
|
|
const text = String(value || '');
|
|
const suffixStart = mention.end < text.length && /\s/.test(text[mention.end]) ? mention.end + 1 : mention.end;
|
|
const inserted = '@' + login + ' ';
|
|
return {
|
|
value:text.slice(0, mention.start) + inserted + text.slice(suffixStart),
|
|
caret:mention.start + inserted.length,
|
|
};
|
|
}
|
|
|
|
function create(options) {
|
|
const textarea = options.textarea;
|
|
const listbox = options.listbox;
|
|
const status = options.status;
|
|
const setTimer = options.setTimer || setTimeout;
|
|
const clearTimer = options.clearTimer || clearTimeout;
|
|
const createOption = options.createOption || (() => document.createElement('button'));
|
|
let timer = null;
|
|
let requestId = 0;
|
|
let mention = null;
|
|
let candidates = [];
|
|
let activeIndex = -1;
|
|
|
|
function dismiss() {
|
|
requestId += 1;
|
|
candidates = [];
|
|
activeIndex = -1;
|
|
listbox.replaceChildren();
|
|
listbox.hidden = true;
|
|
textarea.setAttribute('aria-expanded', 'false');
|
|
textarea.removeAttribute('aria-activedescendant');
|
|
status.textContent = '';
|
|
}
|
|
|
|
function activate(index) {
|
|
if (!candidates.length) return;
|
|
activeIndex = (index + candidates.length) % candidates.length;
|
|
listbox.children.forEach((option, optionIndex) => {
|
|
option.setAttribute('aria-selected', optionIndex === activeIndex ? 'true' : 'false');
|
|
});
|
|
textarea.setAttribute('aria-activedescendant', listbox.children[activeIndex].id);
|
|
}
|
|
|
|
function select(index) {
|
|
if (!mention || !candidates[index]) return;
|
|
const result = insertMention(textarea.value, mention, candidates[index].login);
|
|
textarea.value = result.value;
|
|
textarea.setSelectionRange(result.caret, result.caret);
|
|
dismiss();
|
|
textarea.focus();
|
|
textarea.dispatchEvent?.(new Event('input', { bubbles:true }));
|
|
}
|
|
|
|
function render(items) {
|
|
candidates = Array.isArray(items) ? items : [];
|
|
activeIndex = -1;
|
|
listbox.replaceChildren();
|
|
candidates.forEach((candidate, index) => {
|
|
const option = createOption();
|
|
option.type = 'button';
|
|
option.id = listbox.id + '-option-' + index;
|
|
option.className = 'mention-option';
|
|
option.dataset.login = candidate.login;
|
|
option.setAttribute('role', 'option');
|
|
option.setAttribute('aria-selected', 'false');
|
|
option.textContent = '@' + candidate.login + (candidate.name !== candidate.login ? ' · ' + candidate.name : '');
|
|
option.addEventListener('pointerdown', event => {
|
|
event.preventDefault();
|
|
select(index);
|
|
});
|
|
listbox.appendChild(option);
|
|
});
|
|
listbox.hidden = !candidates.length;
|
|
textarea.setAttribute('aria-expanded', candidates.length ? 'true' : 'false');
|
|
status.textContent = candidates.length ? candidates.length + ' teammates found.' : 'No matching teammates.';
|
|
}
|
|
|
|
function schedule() {
|
|
if (timer !== null) clearTimer(timer);
|
|
mention = activeMention(textarea.value, textarea.selectionStart);
|
|
const repository = options.getRepository();
|
|
if (!mention || !repository) {
|
|
dismiss();
|
|
return;
|
|
}
|
|
const currentRequest = ++requestId;
|
|
timer = setTimer(async () => {
|
|
try {
|
|
const items = await options.loadCandidates(repository, mention.query);
|
|
if (currentRequest === requestId && repository === options.getRepository()) render(items);
|
|
} catch (_error) {
|
|
if (currentRequest === requestId) {
|
|
listbox.hidden = true;
|
|
textarea.setAttribute('aria-expanded', 'false');
|
|
status.textContent = 'Teammate suggestions unavailable; you can keep typing.';
|
|
}
|
|
}
|
|
}, options.delayMs ?? 180);
|
|
}
|
|
|
|
function keydown(event) {
|
|
if (event.key === 'Escape' && !listbox.hidden) {
|
|
event.preventDefault();
|
|
dismiss();
|
|
} else if (event.key === 'ArrowDown' && candidates.length) {
|
|
event.preventDefault();
|
|
activate(activeIndex + 1);
|
|
} else if (event.key === 'ArrowUp' && candidates.length) {
|
|
event.preventDefault();
|
|
activate(activeIndex < 0 ? candidates.length - 1 : activeIndex - 1);
|
|
} else if (event.key === 'Enter' && activeIndex >= 0) {
|
|
event.preventDefault();
|
|
select(activeIndex);
|
|
}
|
|
}
|
|
|
|
return {
|
|
start() {
|
|
textarea.setAttribute('autocomplete', 'off');
|
|
textarea.setAttribute('aria-autocomplete', 'list');
|
|
textarea.setAttribute('aria-controls', listbox.id);
|
|
textarea.setAttribute('aria-expanded', 'false');
|
|
textarea.addEventListener('input', schedule);
|
|
textarea.addEventListener('click', schedule);
|
|
textarea.addEventListener('keydown', keydown);
|
|
},
|
|
dismiss,
|
|
};
|
|
}
|
|
|
|
return { activeMention, insertMention, create };
|
|
});
|