From 23a02c87d9db796f3fb640b326c7a373d4dcdec8 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sat, 18 Apr 2026 19:30:15 +0000 Subject: [PATCH] settings-site: phase 6 accessibility (ARIA combobox/listbox pattern, keyboard nav, modal focus trap, toast a11y, contrast + typography fixes) --- settings-site/public/css/main.css | 78 +++++++++++- settings-site/public/index.html | 6 +- settings-site/public/js/app.js | 6 +- settings-site/public/js/discord.js | 184 ++++++++++++++++++++++++++--- settings-site/public/js/util.js | 133 ++++++++++++++++++++- 5 files changed, 376 insertions(+), 31 deletions(-) diff --git a/settings-site/public/css/main.css b/settings-site/public/css/main.css index 969445e..64fa6e3 100644 --- a/settings-site/public/css/main.css +++ b/settings-site/public/css/main.css @@ -22,7 +22,7 @@ --success: #7EE0A3; --text: #EFEEE8; - --text-muted: #9CA3AE; + --text-muted: #a0a0a8; --text-dim: #6B7280; --sidebar-width: 260px; @@ -262,11 +262,10 @@ body::before { .field.full-width { grid-column: 1 / -1; } .field label { font-family: var(--font-title); - font-size: 10px; + font-size: 13px; font-weight: 700; color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.16em; + letter-spacing: 0; } .field input, .field select, @@ -900,3 +899,74 @@ body::before { #toast-container { right: 12px; left: 12px; top: 64px; } .toast { max-width: none; } } + +/* ---------- Accessibility (Phase 6) ---------- */ + +/* Universal keyboard-focus indicator. Kept narrow: only shown when the + browser's focus heuristic says this is a keyboard user (never on click). + Pointer-driven focus still uses the component-specific hover/active/border + treatments defined above. */ +a:focus-visible, +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +[role="combobox"]:focus-visible, +[role="option"]:focus-visible, +[tabindex]:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} + +/* Smart-select option keyboard navigation. Same visual as :hover so + pointer and keyboard users see the same highlight on the active row. */ +.ss-option:focus { + background: var(--primary-dim-2); + color: var(--primary); +} +.ss-option[aria-selected="true"] { + background: var(--primary-dim); + color: var(--primary); +} +.ss-option:focus-visible { + outline: 2px solid var(--primary); + outline-offset: -2px; +} + +/* Combobox trigger shows an explicit focus ring in addition to the + border-color change, so keyboard users can see it against the dark bg. */ +.smart-select-display:focus-visible { + border-color: var(--primary); + outline: 2px solid var(--primary); + outline-offset: 2px; +} + +/* Toast close button */ +.toast { + display: flex; + align-items: center; + gap: 10px; +} +.toast-message { flex: 1; } +.toast-close { + background: transparent; + border: none; + color: currentColor; + cursor: pointer; + padding: 2px 6px; + font-size: 16px; + line-height: 1; + opacity: 0.7; + font-family: inherit; +} +.toast-close:hover { opacity: 1; } + +/* Multi-select chip removal button (was a , now a diff --git a/settings-site/public/js/app.js b/settings-site/public/js/app.js index 3e52e33..909fbbd 100644 --- a/settings-site/public/js/app.js +++ b/settings-site/public/js/app.js @@ -34,11 +34,11 @@ function openScheduleModal() { const modal = document.getElementById('schedule-modal'); - modal.classList.remove('hidden'); const dt = document.getElementById('schedule-datetime'); const min = Util.formatLocalDateTime(new Date(Date.now() + 60000)); dt.min = min; dt.value = min; + Util.openModal(modal, { initialFocus: '#schedule-datetime' }); } async function confirmScheduledRestart() { @@ -50,7 +50,7 @@ headers: Util.csrfHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ mode: 'scheduled', scheduledFor: new Date(dt).toISOString() }) }); - document.getElementById('schedule-modal').classList.add('hidden'); + Util.closeModal(document.getElementById('schedule-modal')); Util.showToast(`Restart scheduled for ${new Date(dt).toLocaleString()}`, 'warning'); } @@ -71,7 +71,7 @@ document.getElementById('schedule-restart-btn')?.addEventListener('click', openScheduleModal); document.getElementById('schedule-confirm-btn')?.addEventListener('click', confirmScheduledRestart); document.getElementById('schedule-cancel-btn')?.addEventListener('click', () => { - document.getElementById('schedule-modal').classList.add('hidden'); + Util.closeModal(document.getElementById('schedule-modal')); }); document.getElementById('logout-btn')?.addEventListener('click', doLogout); } diff --git a/settings-site/public/js/discord.js b/settings-site/public/js/discord.js index fe3bf5c..d5bf792 100644 --- a/settings-site/public/js/discord.js +++ b/settings-site/public/js/discord.js @@ -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); } diff --git a/settings-site/public/js/util.js b/settings-site/public/js/util.js index 553d0f3..84cc137 100644 --- a/settings-site/public/js/util.js +++ b/settings-site/public/js/util.js @@ -16,11 +16,59 @@ } function showToast(message, type = 'success') { + const isError = type === 'error'; + const timeoutMs = isError ? 6000 : 3500; + const toast = document.createElement('div'); toast.className = `toast toast-${type}`; - toast.textContent = message; + // status (polite) for info/success/warning; alert (assertive) for errors. + toast.setAttribute('role', isError ? 'alert' : 'status'); + toast.setAttribute('aria-live', isError ? 'assertive' : 'polite'); + toast.setAttribute('aria-atomic', 'true'); + + const messageEl = document.createElement('span'); + messageEl.className = 'toast-message'; + messageEl.textContent = message; + toast.appendChild(messageEl); + + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'toast-close'; + closeBtn.setAttribute('aria-label', 'Dismiss'); + closeBtn.textContent = '×'; + toast.appendChild(closeBtn); + + // Auto-dismiss with hover-to-pause. + let remaining = timeoutMs; + let startedAt = 0; + let timer = null; + + function dismiss() { + if (timer) clearTimeout(timer); + timer = null; + if (toast.parentNode) toast.remove(); + } + + function scheduleDismiss() { + startedAt = Date.now(); + timer = setTimeout(dismiss, remaining); + } + + function pauseDismiss() { + if (!timer) return; + clearTimeout(timer); + timer = null; + remaining -= Date.now() - startedAt; + } + + closeBtn.addEventListener('click', dismiss); + toast.addEventListener('mouseenter', pauseDismiss); + toast.addEventListener('mouseleave', scheduleDismiss); + toast.addEventListener('focusin', pauseDismiss); + toast.addEventListener('focusout', scheduleDismiss); + document.getElementById('toast-container').appendChild(toast); - setTimeout(() => toast.remove(), 3500); + scheduleDismiss(); } function formatLocalDateTime(d) { @@ -40,6 +88,84 @@ if (toggle) toggle.setAttribute('aria-expanded', String(open)); } + // ---------- Modal focus management ---------- + + const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled]):not([type="hidden"])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])' + ].join(','); + + function getFocusableElements(container) { + return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)) + .filter(el => !el.hasAttribute('inert') && el.offsetParent !== null); + } + + // Per-modal teardown state; WeakMap keys by modal element to support many. + const modalState = new WeakMap(); + + function openModal(modalEl, options = {}) { + if (!modalEl) return; + if (modalState.has(modalEl)) return; // already open + + const previouslyFocused = document.activeElement; + modalEl.classList.remove('hidden'); + + // Find something to focus. Prefer options.initialFocus, then first field, then modal card. + const card = modalEl.querySelector('.modal-card') || modalEl; + if (!card.hasAttribute('tabindex')) card.setAttribute('tabindex', '-1'); + const initial = options.initialFocus + ? modalEl.querySelector(options.initialFocus) + : getFocusableElements(modalEl)[0] || card; + initial?.focus(); + + const onKeydown = (e) => { + if (e.key === 'Escape') { + e.preventDefault(); + closeModal(modalEl); + options.onClose?.(); + return; + } + if (e.key !== 'Tab') return; + const focusables = getFocusableElements(modalEl); + if (focusables.length === 0) { + e.preventDefault(); + card.focus(); + return; + } + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + }; + modalEl.addEventListener('keydown', onKeydown); + + modalState.set(modalEl, { previouslyFocused, onKeydown }); + } + + function closeModal(modalEl) { + if (!modalEl) return; + const state = modalState.get(modalEl); + modalEl.classList.add('hidden'); + if (!state) return; + modalEl.removeEventListener('keydown', state.onKeydown); + modalState.delete(modalEl); + const target = state.previouslyFocused; + if (target && typeof target.focus === 'function' && document.body.contains(target)) { + target.focus(); + } else { + document.body.focus?.(); + } + } + window.Util = { fetchCsrfToken, csrfHeaders, @@ -47,6 +173,9 @@ formatLocalDateTime, isMobileViewport, setSidebarOpen, + openModal, + closeModal, + getFocusableElements, MOBILE_BREAKPOINT }; })();