400 lines
14 KiB
JavaScript
400 lines
14 KiB
JavaScript
(function () {
|
|
'use strict';
|
|
|
|
let guildData = null;
|
|
let guildDataPromise = null;
|
|
let nextDropdownId = 0;
|
|
|
|
async function fetchGuildData() {
|
|
if (guildData) return guildData;
|
|
if (guildDataPromise) return guildDataPromise;
|
|
guildDataPromise = fetch('/api/discord/guild')
|
|
.then(r => r.json())
|
|
.then(data => { guildData = data; return data; })
|
|
.catch(() => ({ channels: [], roles: [], members: [], categories: [] }));
|
|
return guildDataPromise;
|
|
}
|
|
|
|
async function renderChannelSelect(el, currentValue, filter) {
|
|
const data = await fetchGuildData();
|
|
const channels = filter ? data.channels.filter(filter) : data.channels;
|
|
renderSmartSelect(el, channels.map(c => ({
|
|
id: c.id,
|
|
label: `#${c.name}`,
|
|
sub: c.parentId ? (data.categories.find(cat => cat.id === c.parentId)?.name || null) : null
|
|
})), currentValue);
|
|
}
|
|
|
|
async function renderCategorySelect(el, currentValue) {
|
|
const data = await fetchGuildData();
|
|
renderSmartSelect(el, data.categories.map(c => ({ id: c.id, label: c.name })), currentValue);
|
|
}
|
|
|
|
async function renderRoleSelect(el, currentValue) {
|
|
const data = await fetchGuildData();
|
|
renderSmartSelect(el, data.roles.map(r => ({ id: r.id, label: `@${r.name}`, color: r.color })), currentValue);
|
|
}
|
|
|
|
async function renderMemberSelect(el, currentValue) {
|
|
const data = await fetchGuildData();
|
|
renderSmartSelect(el, data.members.map(m => ({
|
|
id: m.id, label: m.displayName, sub: `@${m.username}`, avatar: m.avatar
|
|
})), currentValue);
|
|
}
|
|
|
|
async function renderMultiMemberSelect(el, currentValue) {
|
|
const data = await fetchGuildData();
|
|
const currentIds = (currentValue || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
renderMultiSelect(el, data.members.map(m => ({
|
|
id: m.id, label: m.displayName, sub: `@${m.username}`, avatar: m.avatar
|
|
})), currentIds);
|
|
}
|
|
|
|
function buildOptionRow(opt, { selected = false } = {}) {
|
|
const item = document.createElement('div');
|
|
item.className = 'ss-option' + (selected ? ' selected' : '');
|
|
if (opt.avatar) {
|
|
const img = document.createElement('img');
|
|
img.className = 'ss-avatar';
|
|
img.src = opt.avatar;
|
|
img.alt = '';
|
|
item.appendChild(img);
|
|
}
|
|
if (opt.color && opt.color !== '#000000') {
|
|
const dot = document.createElement('span');
|
|
dot.className = 'ss-dot';
|
|
dot.style.background = opt.color;
|
|
item.appendChild(dot);
|
|
}
|
|
const label = document.createElement('span');
|
|
label.className = 'ss-label';
|
|
label.textContent = opt.label;
|
|
item.appendChild(label);
|
|
if (opt.sub) {
|
|
const sub = document.createElement('span');
|
|
sub.className = 'ss-sub';
|
|
sub.textContent = opt.sub;
|
|
item.appendChild(sub);
|
|
}
|
|
return item;
|
|
}
|
|
|
|
function setDisplayValue(displayEl, opt) {
|
|
displayEl.replaceChildren();
|
|
const labelSpan = document.createElement('span');
|
|
labelSpan.className = 'ss-label';
|
|
labelSpan.textContent = opt.label;
|
|
const idSpan = document.createElement('span');
|
|
idSpan.className = 'ss-id';
|
|
idSpan.textContent = opt.id;
|
|
displayEl.appendChild(labelSpan);
|
|
displayEl.appendChild(idSpan);
|
|
}
|
|
|
|
function setDisplayPlaceholder(displayEl, text) {
|
|
displayEl.replaceChildren();
|
|
const placeholder = document.createElement('span');
|
|
placeholder.className = 'ss-placeholder';
|
|
placeholder.textContent = text;
|
|
displayEl.appendChild(placeholder);
|
|
}
|
|
|
|
function getFieldLabelText(inputEl) {
|
|
const field = inputEl.closest ? inputEl.closest('.field') : null;
|
|
const label = field ? field.querySelector('label') : null;
|
|
const text = label && label.textContent ? label.textContent.trim() : '';
|
|
return text || (inputEl.dataset?.key || 'value');
|
|
}
|
|
|
|
function createDropdown(options, opts) {
|
|
const { multi = false, getCurrentId = () => null, isExcluded = () => false, onChoose, onClear } = opts;
|
|
|
|
const listId = `ss-listbox-${++nextDropdownId}`;
|
|
|
|
const dropdown = document.createElement('div');
|
|
dropdown.className = 'smart-select-dropdown hidden';
|
|
|
|
const search = document.createElement('input');
|
|
search.type = 'text';
|
|
search.placeholder = 'Search...';
|
|
search.className = 'ss-search';
|
|
search.setAttribute('aria-label', 'Search options');
|
|
search.setAttribute('aria-controls', listId);
|
|
search.setAttribute('autocomplete', 'off');
|
|
|
|
const list = document.createElement('div');
|
|
list.className = 'ss-list';
|
|
list.id = listId;
|
|
list.setAttribute('role', 'listbox');
|
|
if (multi) list.setAttribute('aria-multiselectable', 'true');
|
|
|
|
function getListOptions() {
|
|
return Array.from(list.querySelectorAll('[role="option"]'));
|
|
}
|
|
|
|
function focusOption(index) {
|
|
const opts = getListOptions();
|
|
if (opts.length === 0) return;
|
|
const i = ((index % opts.length) + opts.length) % opts.length;
|
|
opts[i].focus();
|
|
}
|
|
|
|
list.addEventListener('keydown', (e) => {
|
|
const opts = getListOptions();
|
|
if (opts.length === 0) return;
|
|
const current = opts.indexOf(document.activeElement);
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
focusOption(current < 0 ? 0 : current + 1);
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
focusOption(current <= 0 ? opts.length - 1 : current - 1);
|
|
} else if (e.key === 'Home') {
|
|
e.preventDefault();
|
|
focusOption(0);
|
|
} else if (e.key === 'End') {
|
|
e.preventDefault();
|
|
focusOption(opts.length - 1);
|
|
} else if (e.key === 'Enter' || e.key === ' ') {
|
|
if (current >= 0) {
|
|
e.preventDefault();
|
|
opts[current].click();
|
|
}
|
|
}
|
|
});
|
|
|
|
search.addEventListener('keydown', (e) => {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
focusOption(0);
|
|
}
|
|
});
|
|
|
|
function renderOptions(filter = '') {
|
|
list.replaceChildren();
|
|
if (!multi && onClear) {
|
|
const clearOpt = document.createElement('div');
|
|
clearOpt.className = 'ss-option ss-clear';
|
|
clearOpt.setAttribute('role', 'option');
|
|
clearOpt.setAttribute('aria-selected', 'false');
|
|
clearOpt.setAttribute('tabindex', '-1');
|
|
clearOpt.textContent = 'Clear (not set)';
|
|
clearOpt.addEventListener('click', onClear);
|
|
list.appendChild(clearOpt);
|
|
}
|
|
const lq = filter.toLowerCase();
|
|
const filtered = options.filter(o => {
|
|
if (isExcluded(o.id)) return false;
|
|
if (!filter) return true;
|
|
if (multi) {
|
|
return o.label.toLowerCase().includes(lq) || o.id.includes(filter);
|
|
}
|
|
return o.label.toLowerCase().includes(lq)
|
|
|| (o.sub || '').toLowerCase().includes(lq)
|
|
|| o.id.includes(filter);
|
|
});
|
|
const currentId = getCurrentId();
|
|
for (const opt of filtered.slice(0, 50)) {
|
|
const isCurrent = !multi && opt.id === currentId;
|
|
const item = buildOptionRow(opt, { selected: isCurrent });
|
|
item.setAttribute('role', 'option');
|
|
item.setAttribute('aria-selected', String(isCurrent));
|
|
item.setAttribute('tabindex', '-1');
|
|
item.addEventListener('click', () => onChoose(opt));
|
|
list.appendChild(item);
|
|
}
|
|
}
|
|
|
|
search.addEventListener('input', () => renderOptions(search.value));
|
|
|
|
dropdown.appendChild(search);
|
|
dropdown.appendChild(list);
|
|
|
|
renderOptions();
|
|
|
|
return { dropdown, search, list, renderOptions, listId };
|
|
}
|
|
|
|
function renderSmartSelect(inputEl, options, currentValue) {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'smart-select';
|
|
|
|
const display = document.createElement('div');
|
|
display.className = 'smart-select-display';
|
|
display.setAttribute('role', 'combobox');
|
|
display.setAttribute('tabindex', '0');
|
|
display.setAttribute('aria-haspopup', 'listbox');
|
|
display.setAttribute('aria-expanded', 'false');
|
|
display.setAttribute('aria-label', getFieldLabelText(inputEl));
|
|
|
|
const current = options.find(o => o.id === currentValue);
|
|
if (current) setDisplayValue(display, current);
|
|
else setDisplayPlaceholder(display, 'Not set');
|
|
|
|
const ddApi = createDropdown(options, {
|
|
multi: false,
|
|
getCurrentId: () => inputEl.value,
|
|
onChoose: (opt) => {
|
|
inputEl.value = opt.id;
|
|
setDisplayValue(display, opt);
|
|
closeDropdown(true);
|
|
inputEl.dispatchEvent(new Event('change'));
|
|
},
|
|
onClear: () => {
|
|
inputEl.value = '';
|
|
setDisplayPlaceholder(display, 'Not set');
|
|
closeDropdown(true);
|
|
inputEl.dispatchEvent(new Event('change'));
|
|
}
|
|
});
|
|
display.setAttribute('aria-controls', ddApi.listId);
|
|
|
|
function isOpen() { return !ddApi.dropdown.classList.contains('hidden'); }
|
|
function openDropdown() {
|
|
ddApi.dropdown.classList.remove('hidden');
|
|
display.setAttribute('aria-expanded', 'true');
|
|
ddApi.search.focus();
|
|
}
|
|
function closeDropdown(focusTrigger = false) {
|
|
ddApi.dropdown.classList.add('hidden');
|
|
display.setAttribute('aria-expanded', 'false');
|
|
if (focusTrigger) display.focus();
|
|
}
|
|
|
|
display.addEventListener('click', () => {
|
|
if (isOpen()) closeDropdown();
|
|
else openDropdown();
|
|
});
|
|
display.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
if (isOpen()) ddApi.search.focus();
|
|
else openDropdown();
|
|
}
|
|
});
|
|
|
|
ddApi.dropdown.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
closeDropdown(true);
|
|
} else if (e.key === 'Tab') {
|
|
// Don't trap — close so the dropdown doesn't linger while focus moves on.
|
|
closeDropdown();
|
|
}
|
|
});
|
|
|
|
document.addEventListener('click', (e) => {
|
|
if (!wrapper.contains(e.target) && isOpen()) closeDropdown();
|
|
});
|
|
|
|
wrapper.appendChild(display);
|
|
wrapper.appendChild(ddApi.dropdown);
|
|
inputEl.style.display = 'none';
|
|
inputEl.parentNode.insertBefore(wrapper, inputEl.nextSibling);
|
|
}
|
|
|
|
function renderMultiSelect(inputEl, options, currentIds) {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'smart-select';
|
|
const selected = new Set(currentIds);
|
|
const fieldLabel = getFieldLabelText(inputEl);
|
|
|
|
function updateInput() {
|
|
inputEl.value = [...selected].join(',');
|
|
inputEl.dispatchEvent(new Event('change'));
|
|
}
|
|
|
|
const chipsEl = document.createElement('div');
|
|
chipsEl.className = 'ss-chips';
|
|
chipsEl.setAttribute('role', 'list');
|
|
chipsEl.setAttribute('aria-label', `${fieldLabel} (selected)`);
|
|
|
|
function renderChips() {
|
|
chipsEl.replaceChildren();
|
|
for (const id of selected) {
|
|
const opt = options.find(o => o.id === id);
|
|
const label = opt ? opt.label : id;
|
|
const chip = document.createElement('button');
|
|
chip.type = 'button';
|
|
chip.className = 'ss-option ss-chip selected';
|
|
chip.textContent = label;
|
|
chip.setAttribute('aria-label', `Remove ${label}`);
|
|
chip.title = 'Click to remove';
|
|
chip.addEventListener('click', () => { selected.delete(id); renderChips(); updateInput(); });
|
|
chipsEl.appendChild(chip);
|
|
}
|
|
}
|
|
|
|
const addBtn = document.createElement('div');
|
|
addBtn.className = 'smart-select-display';
|
|
addBtn.setAttribute('role', 'combobox');
|
|
addBtn.setAttribute('tabindex', '0');
|
|
addBtn.setAttribute('aria-haspopup', 'listbox');
|
|
addBtn.setAttribute('aria-expanded', 'false');
|
|
addBtn.setAttribute('aria-label', `Add ${fieldLabel}`);
|
|
setDisplayPlaceholder(addBtn, '+ Add');
|
|
|
|
const ddApi = createDropdown(options, {
|
|
multi: true,
|
|
isExcluded: (id) => selected.has(id),
|
|
onChoose: (opt) => {
|
|
selected.add(opt.id);
|
|
renderChips();
|
|
ddApi.renderOptions(ddApi.search.value);
|
|
updateInput();
|
|
// Keep dropdown open so the user can add more; refocus list for keyboard flow.
|
|
const first = ddApi.list.querySelector('[role="option"]');
|
|
if (first) first.focus();
|
|
}
|
|
});
|
|
addBtn.setAttribute('aria-controls', ddApi.listId);
|
|
|
|
function isOpen() { return !ddApi.dropdown.classList.contains('hidden'); }
|
|
function openDropdown() {
|
|
ddApi.dropdown.classList.remove('hidden');
|
|
addBtn.setAttribute('aria-expanded', 'true');
|
|
ddApi.search.focus();
|
|
}
|
|
function closeDropdown(focusTrigger = false) {
|
|
ddApi.dropdown.classList.add('hidden');
|
|
addBtn.setAttribute('aria-expanded', 'false');
|
|
if (focusTrigger) addBtn.focus();
|
|
}
|
|
|
|
addBtn.addEventListener('click', () => {
|
|
if (isOpen()) closeDropdown();
|
|
else openDropdown();
|
|
});
|
|
addBtn.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
if (isOpen()) ddApi.search.focus();
|
|
else openDropdown();
|
|
}
|
|
});
|
|
|
|
ddApi.dropdown.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
closeDropdown(true);
|
|
} else if (e.key === 'Tab') {
|
|
closeDropdown();
|
|
}
|
|
});
|
|
|
|
renderChips();
|
|
|
|
document.addEventListener('click', (e) => {
|
|
if (!wrapper.contains(e.target) && isOpen()) closeDropdown();
|
|
});
|
|
|
|
wrapper.appendChild(chipsEl);
|
|
wrapper.appendChild(addBtn);
|
|
wrapper.appendChild(ddApi.dropdown);
|
|
inputEl.style.display = 'none';
|
|
inputEl.parentNode.insertBefore(wrapper, inputEl.nextSibling);
|
|
}
|
|
|
|
window.DiscordFields = { fetchGuildData, renderChannelSelect, renderCategorySelect, renderRoleSelect, renderMemberSelect, renderMultiMemberSelect };
|
|
})();
|