settings-site: phase 6 accessibility (ARIA combobox/listbox pattern, keyboard nav, modal focus trap, toast a11y, contrast + typography fixes)

This commit is contained in:
2026-04-18 19:30:15 +00:00
parent 0f62fb9020
commit 23a02c87d9
5 changed files with 376 additions and 31 deletions

View File

@@ -3,6 +3,7 @@
let guildData = null;
let guildDataPromise = null;
let nextDropdownId = 0;
async function fetchGuildData() {
if (guildData) return guildData;
@@ -98,9 +99,18 @@
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';
@@ -108,15 +118,66 @@
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);
@@ -134,7 +195,11 @@
});
const currentId = getCurrentId();
for (const opt of filtered.slice(0, 50)) {
const item = buildOptionRow(opt, { selected: !multi && opt.id === currentId });
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);
}
@@ -147,7 +212,7 @@
renderOptions();
return { dropdown, search, list, renderOptions };
return { dropdown, search, list, renderOptions, listId };
}
function renderSmartSelect(inputEl, options, currentValue) {
@@ -156,37 +221,74 @@
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 { dropdown, search } = createDropdown(options, {
const ddApi = createDropdown(options, {
multi: false,
getCurrentId: () => inputEl.value,
onChoose: (opt) => {
inputEl.value = opt.id;
setDisplayValue(display, opt);
dropdown.classList.add('hidden');
closeDropdown(true);
inputEl.dispatchEvent(new Event('change'));
},
onClear: () => {
inputEl.value = '';
setDisplayPlaceholder(display, 'Not set');
dropdown.classList.add('hidden');
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', () => {
dropdown.classList.toggle('hidden');
if (!dropdown.classList.contains('hidden')) search.focus();
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)) dropdown.classList.add('hidden');
if (!wrapper.contains(e.target) && isOpen()) closeDropdown();
});
wrapper.appendChild(display);
wrapper.appendChild(dropdown);
wrapper.appendChild(ddApi.dropdown);
inputEl.style.display = 'none';
inputEl.parentNode.insertBefore(wrapper, inputEl.nextSibling);
}
@@ -195,6 +297,7 @@
const wrapper = document.createElement('div');
wrapper.className = 'smart-select';
const selected = new Set(currentIds);
const fieldLabel = getFieldLabelText(inputEl);
function updateInput() {
inputEl.value = [...selected].join(',');
@@ -203,14 +306,19 @@
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 chip = document.createElement('span');
const label = opt ? opt.label : id;
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'ss-option ss-chip selected';
chip.textContent = opt ? opt.label : id;
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);
@@ -219,32 +327,70 @@
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 { dropdown, search, renderOptions } = createDropdown(options, {
const ddApi = createDropdown(options, {
multi: true,
isExcluded: (id) => selected.has(id),
onChoose: (opt) => {
selected.add(opt.id);
renderChips();
renderOptions(search.value);
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();
addBtn.addEventListener('click', () => {
dropdown.classList.toggle('hidden');
if (!dropdown.classList.contains('hidden')) search.focus();
});
document.addEventListener('click', (e) => {
if (!wrapper.contains(e.target)) dropdown.classList.add('hidden');
if (!wrapper.contains(e.target) && isOpen()) closeDropdown();
});
wrapper.appendChild(chipsEl);
wrapper.appendChild(addBtn);
wrapper.appendChild(dropdown);
wrapper.appendChild(ddApi.dropdown);
inputEl.style.display = 'none';
inputEl.parentNode.insertBefore(wrapper, inputEl.nextSibling);
}