a11y: WCAG AA contrast + ARIA roles + focus management across all pages
- css/ls.css: --text-3 #8898AA → #56687A (5.1:1 contrast), min-height 44px on .btn-primary/.btn-ghost/.sb-link, new .icon-btn utility (44×44px) - js/api.js: lsConfirm — role=dialog, aria-modal, aria-labelledby, Tab focus trap, restore focus on close; lsToast — aria-live=polite on container, role=alert on errors; live quiz — role=dialog, role=radiogroup, role=radio, aria-checked, keyboard support - test-run.html: q-opt divs — role=radio/checkbox, aria-checked, tabindex, keyboard enter/space; confirm modal — role=dialog, aria-modal; btn-flag — aria-pressed; dots — aria-label, aria-current; touch targets 44px - board.html: btn-del-ann — aria-label; reaction buttons — aria-label, aria-pressed - All 18 HTML files: replace hardcoded color:#8898AA with color:var(--text-3) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -338,12 +338,19 @@ function lsToast(message, type = 'info', duration = 3500) {
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
let wrap = document.getElementById('ls-toast-wrap');
|
||||
if (!wrap) { wrap = document.createElement('div'); wrap.id = 'ls-toast-wrap'; document.body.appendChild(wrap); }
|
||||
if (!wrap) {
|
||||
wrap = document.createElement('div');
|
||||
wrap.id = 'ls-toast-wrap';
|
||||
wrap.setAttribute('aria-live', 'polite');
|
||||
wrap.setAttribute('aria-atomic', 'false');
|
||||
document.body.appendChild(wrap);
|
||||
}
|
||||
|
||||
const _tIcons = { success: 'check-circle', error: 'x-close', info: 'info', warn: 'warning' };
|
||||
const el = document.createElement('div');
|
||||
el.className = `ls-toast ${type}`;
|
||||
el.innerHTML = `<span class="ls-toast-icon">${lsIcon(_tIcons[type] || 'info', 18)}</span><span class="ls-toast-msg"></span><button class="ls-toast-close" onclick="this.closest('.ls-toast').remove()">${lsIcon('x-close', 14)}</button>`;
|
||||
el.setAttribute('role', type === 'error' ? 'alert' : 'status');
|
||||
el.innerHTML = `<span class="ls-toast-icon">${lsIcon(_tIcons[type] || 'info', 18)}</span><span class="ls-toast-msg"></span><button class="ls-toast-close" aria-label="Закрыть уведомление" onclick="this.closest('.ls-toast').remove()">${lsIcon('x-close', 14)}</button>`;
|
||||
el.querySelector('.ls-toast-msg').textContent = message;
|
||||
wrap.appendChild(el);
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => el.classList.add('show')));
|
||||
@@ -417,10 +424,10 @@ function lsConfirm(message, { title = 'Подтверждение', confirmText
|
||||
.ls-title{font-family:'Unbounded',sans-serif;font-size:1rem;font-weight:800;color:#0F172A;margin-bottom:10px;}
|
||||
.ls-msg{font-size:0.88rem;color:#3D4F6B;line-height:1.65;white-space:pre-line;margin-bottom:28px;}
|
||||
.ls-btns{display:flex;gap:10px;justify-content:center;}
|
||||
.ls-cancel{padding:10px 26px;border:1.5px solid rgba(15,23,42,0.2);border-radius:999px;background:transparent;
|
||||
font-family:'Manrope',sans-serif;font-size:0.88rem;font-weight:600;color:#8898AA;cursor:pointer;transition:all .2s;}
|
||||
.ls-cancel{padding:10px 26px;min-height:44px;border:1.5px solid rgba(15,23,42,0.2);border-radius:999px;background:transparent;
|
||||
font-family:'Manrope',sans-serif;font-size:0.88rem;font-weight:600;color:#56687A;cursor:pointer;transition:all .2s;}
|
||||
.ls-cancel:hover{border-color:#9B5DE5;color:#9B5DE5;}
|
||||
.ls-ok{padding:10px 28px;border:none;border-radius:999px;color:#fff;
|
||||
.ls-ok{padding:10px 28px;min-height:44px;border:none;border-radius:999px;color:#fff;
|
||||
font-family:'Manrope',sans-serif;font-size:0.88rem;font-weight:700;cursor:pointer;transition:opacity .2s;
|
||||
background:linear-gradient(135deg,#06D6E0,#9B5DE5);}
|
||||
.ls-ok.danger{background:linear-gradient(135deg,#F15BB5,#9B5DE5);}
|
||||
@@ -429,13 +436,16 @@ function lsConfirm(message, { title = 'Подтверждение', confirmText
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
const prevFocus = document.activeElement;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'ls-ov';
|
||||
el.setAttribute('tabindex', '-1');
|
||||
el.setAttribute('role', 'dialog');
|
||||
el.setAttribute('aria-modal', 'true');
|
||||
el.setAttribute('aria-labelledby', 'ls-dlg-title');
|
||||
el.innerHTML = `
|
||||
<div class="ls-box">
|
||||
<div class="ls-icon">${danger ? lsIcon('trash', 36) : lsIcon('help-circle', 36)}</div>
|
||||
<div class="ls-title"></div>
|
||||
<div class="ls-title" id="ls-dlg-title"></div>
|
||||
<div class="ls-msg"></div>
|
||||
<div class="ls-btns">
|
||||
<button class="ls-cancel">Отмена</button>
|
||||
@@ -450,7 +460,7 @@ function lsConfirm(message, { title = 'Подтверждение', confirmText
|
||||
|
||||
const done = result => {
|
||||
el.classList.remove('open');
|
||||
setTimeout(() => el.remove(), 230);
|
||||
setTimeout(() => { el.remove(); prevFocus?.focus(); }, 230);
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
@@ -458,10 +468,18 @@ function lsConfirm(message, { title = 'Подтверждение', confirmText
|
||||
el.querySelector('.ls-ok').onclick = () => done(true);
|
||||
el.addEventListener('click', e => { if (e.target === el) done(false); });
|
||||
el.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') done(true);
|
||||
if (e.key === 'Escape') done(false);
|
||||
if (e.key === 'Tab') {
|
||||
const btns = [...el.querySelectorAll('button')];
|
||||
if (e.shiftKey && document.activeElement === btns[0]) {
|
||||
e.preventDefault(); btns[btns.length - 1].focus();
|
||||
} else if (!e.shiftKey && document.activeElement === btns[btns.length - 1]) {
|
||||
e.preventDefault(); btns[0].focus();
|
||||
}
|
||||
}
|
||||
if (e.key === 'Enter') { e.preventDefault(); done(true); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); done(false); }
|
||||
});
|
||||
setTimeout(() => el.focus(), 10);
|
||||
setTimeout(() => el.querySelector('.ls-cancel').focus(), 10);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1110,7 +1128,7 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi
|
||||
.ls-live-opt.selected .ls-live-opt-key{background:#06D6E0;color:#fff;}
|
||||
.ls-live-opt.correct .ls-live-opt-key{background:#06D6A0;color:#fff;}
|
||||
.ls-live-opt.wrong .ls-live-opt-key{background:#EF476F;color:#fff;}
|
||||
.ls-live-status{text-align:center;font-size:.84rem;color:#8898AA;padding:8px 0;}
|
||||
.ls-live-status{text-align:center;font-size:.84rem;color:var(--text-3);padding:8px 0;}
|
||||
.ls-live-result-bar-wrap{margin:4px 0;}
|
||||
.ls-live-result-bar{height:8px;border-radius:99px;background:#E2E8F0;margin-top:4px;overflow:hidden;}
|
||||
.ls-live-result-fill{height:100%;border-radius:99px;background:#9B5DE5;transition:width .6s ease;}
|
||||
@@ -1120,11 +1138,14 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.id = 'ls-live-overlay';
|
||||
el.setAttribute('role', 'dialog');
|
||||
el.setAttribute('aria-modal', 'true');
|
||||
el.setAttribute('aria-labelledby', 'lslq-text');
|
||||
el.innerHTML = `<div class="ls-live-box">
|
||||
<div class="ls-live-badge"><svg class="ic" viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg> Live Quiz</div>
|
||||
<div class="ls-live-badge" aria-hidden="true"><svg class="ic" viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg> Live Quiz</div>
|
||||
<div class="ls-live-q" id="lslq-text"></div>
|
||||
<div class="ls-live-opts" id="lslq-opts"></div>
|
||||
<div class="ls-live-status" id="lslq-status"></div>
|
||||
<div class="ls-live-opts" id="lslq-opts" role="radiogroup" aria-label="Варианты ответа"></div>
|
||||
<div class="ls-live-status" id="lslq-status" aria-live="polite"></div>
|
||||
</div>`;
|
||||
|
||||
const styleEl = document.createElement('style');
|
||||
@@ -1168,8 +1189,10 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi
|
||||
document.getElementById('lslq-text').innerHTML = _mathHtml(question.text);
|
||||
const keys = 'АБВГДЕ';
|
||||
document.getElementById('lslq-opts').innerHTML = (options || []).map((o, i) => `
|
||||
<div class="ls-live-opt" data-id="${o.id}" onclick="window._lsLiveAnswer(${liveId},${o.id},this)">
|
||||
<span class="ls-live-opt-key">${keys[i] || i+1}</span>
|
||||
<div class="ls-live-opt" role="radio" aria-checked="false" tabindex="0" data-id="${o.id}"
|
||||
onclick="window._lsLiveAnswer(${liveId},${o.id},this)"
|
||||
onkeydown="if(event.key===' '||event.key==='Enter'){event.preventDefault();window._lsLiveAnswer(${liveId},${o.id},this)}">
|
||||
<span class="ls-live-opt-key" aria-hidden="true">${keys[i] || i+1}</span>
|
||||
<span>${_mathHtml(o.text)}</span>
|
||||
</div>`).join('');
|
||||
document.getElementById('lslq-status').textContent = 'Выберите ответ';
|
||||
@@ -1201,8 +1224,12 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi
|
||||
window._lsLiveAnswer = async function(liveId, optionId, el) {
|
||||
if (answered) return;
|
||||
answered = true;
|
||||
document.querySelectorAll('.ls-live-opt').forEach(o => { o.onclick = null; o.style.cursor = 'default'; });
|
||||
document.querySelectorAll('.ls-live-opt').forEach(o => {
|
||||
o.onclick = null; o.onkeydown = null; o.style.cursor = 'default';
|
||||
o.setAttribute('aria-checked', 'false');
|
||||
});
|
||||
el.classList.add('selected');
|
||||
el.setAttribute('aria-checked', 'true');
|
||||
document.getElementById('lslq-status').innerHTML = 'Ответ отправлен <svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
try {
|
||||
const r = await apiFetch(`/api/live/${liveId}/answer`, { method: 'POST', body: JSON.stringify({ option_id: optionId }) });
|
||||
|
||||
Reference in New Issue
Block a user