settings-site: phase 6 accessibility (ARIA combobox/listbox pattern, keyboard nav, modal focus trap, toast a11y, contrast + typography fixes)
This commit is contained in:
@@ -22,7 +22,7 @@
|
|||||||
--success: #7EE0A3;
|
--success: #7EE0A3;
|
||||||
|
|
||||||
--text: #EFEEE8;
|
--text: #EFEEE8;
|
||||||
--text-muted: #9CA3AE;
|
--text-muted: #a0a0a8;
|
||||||
--text-dim: #6B7280;
|
--text-dim: #6B7280;
|
||||||
|
|
||||||
--sidebar-width: 260px;
|
--sidebar-width: 260px;
|
||||||
@@ -262,11 +262,10 @@ body::before {
|
|||||||
.field.full-width { grid-column: 1 / -1; }
|
.field.full-width { grid-column: 1 / -1; }
|
||||||
.field label {
|
.field label {
|
||||||
font-family: var(--font-title);
|
font-family: var(--font-title);
|
||||||
font-size: 10px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
text-transform: uppercase;
|
letter-spacing: 0;
|
||||||
letter-spacing: 0.16em;
|
|
||||||
}
|
}
|
||||||
.field input,
|
.field input,
|
||||||
.field select,
|
.field select,
|
||||||
@@ -900,3 +899,74 @@ body::before {
|
|||||||
#toast-container { right: 12px; left: 12px; top: 64px; }
|
#toast-container { right: 12px; left: 12px; top: 64px; }
|
||||||
.toast { max-width: none; }
|
.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 <span>, now a <button>) keeps
|
||||||
|
the same visual as the previous span chip. */
|
||||||
|
button.ss-chip {
|
||||||
|
font-family: inherit;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -371,10 +371,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Schedule modal -->
|
<!-- Schedule modal -->
|
||||||
<div id="schedule-modal" class="modal hidden">
|
<div id="schedule-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="schedule-modal-title">
|
||||||
<div class="modal-card">
|
<div class="modal-card">
|
||||||
<h3>Schedule restart</h3>
|
<h3 id="schedule-modal-title">Schedule restart</h3>
|
||||||
<input type="datetime-local" id="schedule-datetime">
|
<input type="datetime-local" id="schedule-datetime" aria-label="Restart date and time">
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" id="schedule-confirm-btn">Schedule</button>
|
<button type="button" id="schedule-confirm-btn">Schedule</button>
|
||||||
<button type="button" id="schedule-cancel-btn" class="secondary">Cancel</button>
|
<button type="button" id="schedule-cancel-btn" class="secondary">Cancel</button>
|
||||||
|
|||||||
@@ -34,11 +34,11 @@
|
|||||||
|
|
||||||
function openScheduleModal() {
|
function openScheduleModal() {
|
||||||
const modal = document.getElementById('schedule-modal');
|
const modal = document.getElementById('schedule-modal');
|
||||||
modal.classList.remove('hidden');
|
|
||||||
const dt = document.getElementById('schedule-datetime');
|
const dt = document.getElementById('schedule-datetime');
|
||||||
const min = Util.formatLocalDateTime(new Date(Date.now() + 60000));
|
const min = Util.formatLocalDateTime(new Date(Date.now() + 60000));
|
||||||
dt.min = min;
|
dt.min = min;
|
||||||
dt.value = min;
|
dt.value = min;
|
||||||
|
Util.openModal(modal, { initialFocus: '#schedule-datetime' });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmScheduledRestart() {
|
async function confirmScheduledRestart() {
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
headers: Util.csrfHeaders({ 'Content-Type': 'application/json' }),
|
headers: Util.csrfHeaders({ 'Content-Type': 'application/json' }),
|
||||||
body: JSON.stringify({ mode: 'scheduled', scheduledFor: new Date(dt).toISOString() })
|
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');
|
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-restart-btn')?.addEventListener('click', openScheduleModal);
|
||||||
document.getElementById('schedule-confirm-btn')?.addEventListener('click', confirmScheduledRestart);
|
document.getElementById('schedule-confirm-btn')?.addEventListener('click', confirmScheduledRestart);
|
||||||
document.getElementById('schedule-cancel-btn')?.addEventListener('click', () => {
|
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);
|
document.getElementById('logout-btn')?.addEventListener('click', doLogout);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
let guildData = null;
|
let guildData = null;
|
||||||
let guildDataPromise = null;
|
let guildDataPromise = null;
|
||||||
|
let nextDropdownId = 0;
|
||||||
|
|
||||||
async function fetchGuildData() {
|
async function fetchGuildData() {
|
||||||
if (guildData) return guildData;
|
if (guildData) return guildData;
|
||||||
@@ -98,9 +99,18 @@
|
|||||||
displayEl.appendChild(placeholder);
|
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) {
|
function createDropdown(options, opts) {
|
||||||
const { multi = false, getCurrentId = () => null, isExcluded = () => false, onChoose, onClear } = opts;
|
const { multi = false, getCurrentId = () => null, isExcluded = () => false, onChoose, onClear } = opts;
|
||||||
|
|
||||||
|
const listId = `ss-listbox-${++nextDropdownId}`;
|
||||||
|
|
||||||
const dropdown = document.createElement('div');
|
const dropdown = document.createElement('div');
|
||||||
dropdown.className = 'smart-select-dropdown hidden';
|
dropdown.className = 'smart-select-dropdown hidden';
|
||||||
|
|
||||||
@@ -108,15 +118,66 @@
|
|||||||
search.type = 'text';
|
search.type = 'text';
|
||||||
search.placeholder = 'Search...';
|
search.placeholder = 'Search...';
|
||||||
search.className = 'ss-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');
|
const list = document.createElement('div');
|
||||||
list.className = 'ss-list';
|
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 = '') {
|
function renderOptions(filter = '') {
|
||||||
list.replaceChildren();
|
list.replaceChildren();
|
||||||
if (!multi && onClear) {
|
if (!multi && onClear) {
|
||||||
const clearOpt = document.createElement('div');
|
const clearOpt = document.createElement('div');
|
||||||
clearOpt.className = 'ss-option ss-clear';
|
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.textContent = 'Clear (not set)';
|
||||||
clearOpt.addEventListener('click', onClear);
|
clearOpt.addEventListener('click', onClear);
|
||||||
list.appendChild(clearOpt);
|
list.appendChild(clearOpt);
|
||||||
@@ -134,7 +195,11 @@
|
|||||||
});
|
});
|
||||||
const currentId = getCurrentId();
|
const currentId = getCurrentId();
|
||||||
for (const opt of filtered.slice(0, 50)) {
|
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));
|
item.addEventListener('click', () => onChoose(opt));
|
||||||
list.appendChild(item);
|
list.appendChild(item);
|
||||||
}
|
}
|
||||||
@@ -147,7 +212,7 @@
|
|||||||
|
|
||||||
renderOptions();
|
renderOptions();
|
||||||
|
|
||||||
return { dropdown, search, list, renderOptions };
|
return { dropdown, search, list, renderOptions, listId };
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSmartSelect(inputEl, options, currentValue) {
|
function renderSmartSelect(inputEl, options, currentValue) {
|
||||||
@@ -156,37 +221,74 @@
|
|||||||
|
|
||||||
const display = document.createElement('div');
|
const display = document.createElement('div');
|
||||||
display.className = 'smart-select-display';
|
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);
|
const current = options.find(o => o.id === currentValue);
|
||||||
if (current) setDisplayValue(display, current);
|
if (current) setDisplayValue(display, current);
|
||||||
else setDisplayPlaceholder(display, 'Not set');
|
else setDisplayPlaceholder(display, 'Not set');
|
||||||
|
|
||||||
const { dropdown, search } = createDropdown(options, {
|
const ddApi = createDropdown(options, {
|
||||||
multi: false,
|
multi: false,
|
||||||
getCurrentId: () => inputEl.value,
|
getCurrentId: () => inputEl.value,
|
||||||
onChoose: (opt) => {
|
onChoose: (opt) => {
|
||||||
inputEl.value = opt.id;
|
inputEl.value = opt.id;
|
||||||
setDisplayValue(display, opt);
|
setDisplayValue(display, opt);
|
||||||
dropdown.classList.add('hidden');
|
closeDropdown(true);
|
||||||
inputEl.dispatchEvent(new Event('change'));
|
inputEl.dispatchEvent(new Event('change'));
|
||||||
},
|
},
|
||||||
onClear: () => {
|
onClear: () => {
|
||||||
inputEl.value = '';
|
inputEl.value = '';
|
||||||
setDisplayPlaceholder(display, 'Not set');
|
setDisplayPlaceholder(display, 'Not set');
|
||||||
dropdown.classList.add('hidden');
|
closeDropdown(true);
|
||||||
inputEl.dispatchEvent(new Event('change'));
|
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', () => {
|
display.addEventListener('click', () => {
|
||||||
dropdown.classList.toggle('hidden');
|
if (isOpen()) closeDropdown();
|
||||||
if (!dropdown.classList.contains('hidden')) search.focus();
|
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) => {
|
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(display);
|
||||||
wrapper.appendChild(dropdown);
|
wrapper.appendChild(ddApi.dropdown);
|
||||||
inputEl.style.display = 'none';
|
inputEl.style.display = 'none';
|
||||||
inputEl.parentNode.insertBefore(wrapper, inputEl.nextSibling);
|
inputEl.parentNode.insertBefore(wrapper, inputEl.nextSibling);
|
||||||
}
|
}
|
||||||
@@ -195,6 +297,7 @@
|
|||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.className = 'smart-select';
|
wrapper.className = 'smart-select';
|
||||||
const selected = new Set(currentIds);
|
const selected = new Set(currentIds);
|
||||||
|
const fieldLabel = getFieldLabelText(inputEl);
|
||||||
|
|
||||||
function updateInput() {
|
function updateInput() {
|
||||||
inputEl.value = [...selected].join(',');
|
inputEl.value = [...selected].join(',');
|
||||||
@@ -203,14 +306,19 @@
|
|||||||
|
|
||||||
const chipsEl = document.createElement('div');
|
const chipsEl = document.createElement('div');
|
||||||
chipsEl.className = 'ss-chips';
|
chipsEl.className = 'ss-chips';
|
||||||
|
chipsEl.setAttribute('role', 'list');
|
||||||
|
chipsEl.setAttribute('aria-label', `${fieldLabel} (selected)`);
|
||||||
|
|
||||||
function renderChips() {
|
function renderChips() {
|
||||||
chipsEl.replaceChildren();
|
chipsEl.replaceChildren();
|
||||||
for (const id of selected) {
|
for (const id of selected) {
|
||||||
const opt = options.find(o => o.id === id);
|
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.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.title = 'Click to remove';
|
||||||
chip.addEventListener('click', () => { selected.delete(id); renderChips(); updateInput(); });
|
chip.addEventListener('click', () => { selected.delete(id); renderChips(); updateInput(); });
|
||||||
chipsEl.appendChild(chip);
|
chipsEl.appendChild(chip);
|
||||||
@@ -219,32 +327,70 @@
|
|||||||
|
|
||||||
const addBtn = document.createElement('div');
|
const addBtn = document.createElement('div');
|
||||||
addBtn.className = 'smart-select-display';
|
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');
|
setDisplayPlaceholder(addBtn, '+ Add');
|
||||||
|
|
||||||
const { dropdown, search, renderOptions } = createDropdown(options, {
|
const ddApi = createDropdown(options, {
|
||||||
multi: true,
|
multi: true,
|
||||||
isExcluded: (id) => selected.has(id),
|
isExcluded: (id) => selected.has(id),
|
||||||
onChoose: (opt) => {
|
onChoose: (opt) => {
|
||||||
selected.add(opt.id);
|
selected.add(opt.id);
|
||||||
renderChips();
|
renderChips();
|
||||||
renderOptions(search.value);
|
ddApi.renderOptions(ddApi.search.value);
|
||||||
updateInput();
|
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();
|
renderChips();
|
||||||
|
|
||||||
addBtn.addEventListener('click', () => {
|
|
||||||
dropdown.classList.toggle('hidden');
|
|
||||||
if (!dropdown.classList.contains('hidden')) search.focus();
|
|
||||||
});
|
|
||||||
document.addEventListener('click', (e) => {
|
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(chipsEl);
|
||||||
wrapper.appendChild(addBtn);
|
wrapper.appendChild(addBtn);
|
||||||
wrapper.appendChild(dropdown);
|
wrapper.appendChild(ddApi.dropdown);
|
||||||
inputEl.style.display = 'none';
|
inputEl.style.display = 'none';
|
||||||
inputEl.parentNode.insertBefore(wrapper, inputEl.nextSibling);
|
inputEl.parentNode.insertBefore(wrapper, inputEl.nextSibling);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,59 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showToast(message, type = 'success') {
|
function showToast(message, type = 'success') {
|
||||||
|
const isError = type === 'error';
|
||||||
|
const timeoutMs = isError ? 6000 : 3500;
|
||||||
|
|
||||||
const toast = document.createElement('div');
|
const toast = document.createElement('div');
|
||||||
toast.className = `toast toast-${type}`;
|
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);
|
document.getElementById('toast-container').appendChild(toast);
|
||||||
setTimeout(() => toast.remove(), 3500);
|
scheduleDismiss();
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLocalDateTime(d) {
|
function formatLocalDateTime(d) {
|
||||||
@@ -40,6 +88,84 @@
|
|||||||
if (toggle) toggle.setAttribute('aria-expanded', String(open));
|
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 = {
|
window.Util = {
|
||||||
fetchCsrfToken,
|
fetchCsrfToken,
|
||||||
csrfHeaders,
|
csrfHeaders,
|
||||||
@@ -47,6 +173,9 @@
|
|||||||
formatLocalDateTime,
|
formatLocalDateTime,
|
||||||
isMobileViewport,
|
isMobileViewport,
|
||||||
setSidebarOpen,
|
setSidebarOpen,
|
||||||
|
openModal,
|
||||||
|
closeModal,
|
||||||
|
getFocusableElements,
|
||||||
MOBILE_BREAKPOINT
|
MOBILE_BREAKPOINT
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user