feat: LS.modal — общий компонент модалок + миграция /exam9 + /my-students
Новый общий компонент LS.modal (api.js) — companion к LS.confirm.
Универсальная form/content-модалка с консистентным поведением:
LS.modal({
title, content, size: 'sm'|'md'|'lg',
actions: [{label, primary, danger, onClick}],
onClose,
});
// Returns { close, root, body, setBody, setActions, setError }
Стандартное поведение:
- ESC и backdrop-click закрывают (опциональный dismissible:false)
- z-index 9000 (тот же что LS.confirm — без конфликтов)
- Auto-focus первого input/select/textarea/button в body
- prevFocus restore при закрытии
- Анимация scale+translateY .22s
- Адаптив: на мобилках padding уменьшается
CSS-классы .ls-mov / .ls-mod / .ls-mod-hdr / .ls-mod-body / .ls-mod-act
впрыскиваются один раз из api.js (id=ls-modal-style), как и стили
toast/confirm.
Миграция exam9 «Назначить вариант»:
- Убран inline <div class="ex-overlay" id="assign-overlay">…</div>
- Убраны .ax-actions, .ax-btn, .ax-btn-primary, .ax-error, .ax-success
CSS (теперь в общих .ls-mod-* стилях)
- openAssignModal → LS.modal({ title, content: form, actions: [...] })
- Удалены closeAssignModal/onAssignOverlayClick/onAssignEsc — теперь
handle'ит LS.modal
- Удалена unused переменная assignVariantNum (closure теперь над varNum)
exam9.html: −53 строк (CSS + HTML модалки)
app.js: переписан 90 строк → 70 строк
Миграция my-students «Убрать ученика»:
- native confirm() → LS.confirm() с danger-стилизацией
- alert() → LS.toast() для согласованности
Сохранён классroom-овский «ex-overlay»/«ex-panel» CSS (используется
для picker'а вариантов в exam9). Не трогаем classroom.html — у него
своя ecosystem cr-*-overlay.
Дальше — postupенная миграция модалок в textbooks/classes/admin
по мере касания этих страниц. Шаблон установлен.
This commit is contained in:
@@ -225,32 +225,6 @@
|
|||||||
font-family:'Manrope',sans-serif; font-size:.9rem;
|
font-family:'Manrope',sans-serif; font-size:.9rem;
|
||||||
}
|
}
|
||||||
.ax-input:focus { outline:none; border-color:var(--violet); }
|
.ax-input:focus { outline:none; border-color:var(--violet); }
|
||||||
.ax-actions {
|
|
||||||
display:flex; gap:10px; justify-content:flex-end; margin-top:6px;
|
|
||||||
}
|
|
||||||
.ax-btn {
|
|
||||||
padding:9px 18px; border-radius:10px; border:1.5px solid var(--border-h);
|
|
||||||
background:transparent; color:var(--text);
|
|
||||||
font-family:'Manrope',sans-serif; font-size:.88rem; font-weight:700;
|
|
||||||
cursor:pointer; transition:all .15s;
|
|
||||||
}
|
|
||||||
.ax-btn:hover { border-color:var(--text-2); }
|
|
||||||
.ax-btn-primary { background:var(--violet); border-color:var(--violet); color:#fff; }
|
|
||||||
.ax-btn-primary:hover { background:#7e3eca; border-color:#7e3eca; }
|
|
||||||
.ax-btn-primary:disabled { opacity:.5; cursor:not-allowed; }
|
|
||||||
.ax-error {
|
|
||||||
padding:9px 12px; border-radius:8px; background:rgba(241,91,68,.1);
|
|
||||||
border:1px solid rgba(241,91,68,.3); color:#F94144;
|
|
||||||
font-size:.84rem; display:none;
|
|
||||||
}
|
|
||||||
.ax-error.visible { display:block; }
|
|
||||||
.ax-success {
|
|
||||||
padding:9px 12px; border-radius:8px; background:rgba(6,214,160,.1);
|
|
||||||
border:1px solid rgba(6,214,160,.3); color:#06D6A0;
|
|
||||||
font-size:.84rem; display:none;
|
|
||||||
}
|
|
||||||
.ax-success.visible { display:block; }
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.ex-wrap { padding:20px 16px 60px; }
|
.ex-wrap { padding:20px 16px 60px; }
|
||||||
.ex-title { font-size:1.15rem; }
|
.ex-title { font-size:1.15rem; }
|
||||||
@@ -311,33 +285,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ex-overlay" id="assign-overlay" onclick="onAssignOverlayClick(event)">
|
|
||||||
<div class="ex-panel" onclick="event.stopPropagation()" style="width:min(520px,94vw)">
|
|
||||||
<div class="ex-panel-head">
|
|
||||||
<h2 id="assign-title">Назначить вариант</h2>
|
|
||||||
<button class="ex-panel-close" onclick="closeAssignModal()" title="Закрыть">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form class="ax-form" id="assign-form" onsubmit="event.preventDefault(); submitAssign()">
|
|
||||||
<div class="ax-field">
|
|
||||||
<label>Классы</label>
|
|
||||||
<div class="ax-classes" id="ax-classes-list">Загрузка…</div>
|
|
||||||
</div>
|
|
||||||
<div class="ax-field">
|
|
||||||
<label>Срок сдачи (опционально)</label>
|
|
||||||
<input type="datetime-local" class="ax-input" id="ax-deadline" />
|
|
||||||
</div>
|
|
||||||
<div class="ax-error" id="ax-error"></div>
|
|
||||||
<div class="ax-success" id="ax-success"></div>
|
|
||||||
<div class="ax-actions">
|
|
||||||
<button type="button" class="ax-btn" onclick="closeAssignModal()">Отмена</button>
|
|
||||||
<button type="submit" class="ax-btn ax-btn-primary" id="ax-submit">Назначить</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||||||
<script src="/js/api.js"></script>
|
<script src="/js/api.js"></script>
|
||||||
<script src="/js/sidebar.js"></script>
|
<script src="/js/sidebar.js"></script>
|
||||||
|
|||||||
+61
-80
@@ -176,7 +176,6 @@ function selectVariant(num) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Assignment modal ───────────────────────────────────────────── */
|
/* ── Assignment modal ───────────────────────────────────────────── */
|
||||||
let assignVariantNum = null;
|
|
||||||
|
|
||||||
async function loadTeacherClasses() {
|
async function loadTeacherClasses() {
|
||||||
if (teacherClasses) return teacherClasses;
|
if (teacherClasses) return teacherClasses;
|
||||||
@@ -189,94 +188,76 @@ async function loadTeacherClasses() {
|
|||||||
return teacherClasses;
|
return teacherClasses;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openAssignModal(variantNum) {
|
|
||||||
if (!variantTests[variantNum]) return;
|
|
||||||
assignVariantNum = variantNum;
|
|
||||||
document.getElementById('assign-title').textContent = `Назначить «Вариант ${variantNum}» как ДЗ`;
|
|
||||||
document.getElementById('ax-error').classList.remove('visible');
|
|
||||||
document.getElementById('ax-success').classList.remove('visible');
|
|
||||||
document.getElementById('ax-deadline').value = '';
|
|
||||||
document.getElementById('ax-submit').disabled = false;
|
|
||||||
document.getElementById('ax-submit').textContent = 'Назначить';
|
|
||||||
|
|
||||||
const listEl = document.getElementById('ax-classes-list');
|
|
||||||
listEl.textContent = 'Загрузка…';
|
|
||||||
const classes = await loadTeacherClasses();
|
|
||||||
if (!classes.length) {
|
|
||||||
listEl.innerHTML = '<div style="padding:14px;color:var(--text-3);font-size:.85rem">У вас пока нет классов. Создайте класс на странице «Классы».</div>';
|
|
||||||
} else {
|
|
||||||
listEl.innerHTML = classes.map(c => `
|
|
||||||
<label class="ax-class">
|
|
||||||
<input type="checkbox" name="cls" value="${c.id}" />
|
|
||||||
<span class="ax-cname">${escapeHtml(c.name)}</span>
|
|
||||||
<span class="ax-cmeta">${c.member_count || 0} учеников</span>
|
|
||||||
</label>`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('assign-overlay').classList.add('visible');
|
|
||||||
document.addEventListener('keydown', onAssignEsc);
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeAssignModal() {
|
|
||||||
document.getElementById('assign-overlay').classList.remove('visible');
|
|
||||||
document.removeEventListener('keydown', onAssignEsc);
|
|
||||||
assignVariantNum = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onAssignOverlayClick(e) {
|
|
||||||
if (e.target === document.getElementById('assign-overlay')) closeAssignModal();
|
|
||||||
}
|
|
||||||
function onAssignEsc(e) { if (e.key === 'Escape') closeAssignModal(); }
|
|
||||||
|
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));
|
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitAssign() {
|
async function openAssignModal(variantNum) {
|
||||||
const errorEl = document.getElementById('ax-error');
|
if (!variantTests[variantNum]) return;
|
||||||
const successEl = document.getElementById('ax-success');
|
const testId = variantTests[variantNum];
|
||||||
const submitBtn = document.getElementById('ax-submit');
|
|
||||||
errorEl.classList.remove('visible');
|
|
||||||
successEl.classList.remove('visible');
|
|
||||||
|
|
||||||
const checked = Array.from(document.querySelectorAll('#ax-classes-list input[name="cls"]:checked'))
|
// Build body with shared LS.modal helper
|
||||||
.map(el => Number(el.value));
|
const classes = await loadTeacherClasses();
|
||||||
if (!checked.length) {
|
const classesHtml = classes.length
|
||||||
errorEl.textContent = 'Выберите хотя бы один класс';
|
? classes.map(c => `
|
||||||
errorEl.classList.add('visible');
|
<label class="ax-class">
|
||||||
return;
|
<input type="checkbox" name="cls" value="${c.id}" />
|
||||||
}
|
<span class="ax-cname">${escapeHtml(c.name)}</span>
|
||||||
|
<span class="ax-cmeta">${c.member_count || 0} учеников</span>
|
||||||
|
</label>`).join('')
|
||||||
|
: '<div style="padding:14px;color:var(--text-3);font-size:.85rem">У вас пока нет классов. Создайте класс на странице «Классы».</div>';
|
||||||
|
|
||||||
const testId = variantTests[assignVariantNum];
|
const body = `
|
||||||
const deadline = document.getElementById('ax-deadline').value || null;
|
<form class="ax-form" id="assign-form" onsubmit="event.preventDefault()">
|
||||||
if (!testId) { errorEl.textContent = 'Вариант не в банке вопросов'; errorEl.classList.add('visible'); return; }
|
<div class="ax-field">
|
||||||
|
<label>Классы</label>
|
||||||
|
<div class="ax-classes" id="ax-classes-list">${classesHtml}</div>
|
||||||
|
</div>
|
||||||
|
<div class="ax-field">
|
||||||
|
<label>Срок сдачи (опционально)</label>
|
||||||
|
<input type="datetime-local" class="ax-input" id="ax-deadline" />
|
||||||
|
</div>
|
||||||
|
</form>`;
|
||||||
|
|
||||||
submitBtn.disabled = true;
|
const m = LS.modal({
|
||||||
submitBtn.textContent = 'Назначаю…';
|
title: `Назначить «Вариант ${variantNum}» как ДЗ`,
|
||||||
|
content: body,
|
||||||
|
size: 'sm',
|
||||||
|
actions: [
|
||||||
|
{ label: 'Отмена', onClick: () => m.close() },
|
||||||
|
{
|
||||||
|
label: 'Назначить', primary: true,
|
||||||
|
onClick: async () => {
|
||||||
|
const checked = Array.from(m.body.querySelectorAll('input[name="cls"]:checked')).map(el => Number(el.value));
|
||||||
|
if (!checked.length) { m.setError('Выберите хотя бы один класс'); return; }
|
||||||
|
|
||||||
try {
|
const btns = m.root.querySelectorAll('.ls-mod-btn');
|
||||||
const r = await LS.api('/api/assignments/bulk', {
|
btns.forEach(b => b.disabled = true);
|
||||||
method: 'POST',
|
btns[1].textContent = 'Назначаю…';
|
||||||
body: {
|
|
||||||
title: `Экзамен 9 — Вариант ${assignVariantNum}`,
|
try {
|
||||||
class_ids: checked,
|
const r = await LS.api('/api/assignments/bulk', {
|
||||||
mode: 'exam',
|
method: 'POST',
|
||||||
count: 10,
|
body: {
|
||||||
test_id: testId,
|
title: `Экзамен 9 — Вариант ${variantNum}`,
|
||||||
deadline: deadline,
|
class_ids: checked,
|
||||||
is_homework: 1,
|
mode: 'exam', count: 10,
|
||||||
|
test_id: testId,
|
||||||
|
deadline: m.body.querySelector('#ax-deadline').value || null,
|
||||||
|
is_homework: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
LS.toast(`Назначено в ${r.count || checked.length} класс(ах)`, 'success');
|
||||||
|
m.close();
|
||||||
|
} catch (e) {
|
||||||
|
m.setError(e.message || 'Не удалось создать задание');
|
||||||
|
btns.forEach(b => b.disabled = false);
|
||||||
|
btns[1].textContent = 'Назначить';
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
],
|
||||||
successEl.textContent = `Назначено в ${r.count || checked.length} классе(ах)`;
|
});
|
||||||
successEl.classList.add('visible');
|
|
||||||
submitBtn.textContent = 'Готово';
|
|
||||||
setTimeout(closeAssignModal, 1500);
|
|
||||||
} catch (e) {
|
|
||||||
errorEl.textContent = e.message || 'Не удалось создать задание';
|
|
||||||
errorEl.classList.add('visible');
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
submitBtn.textContent = 'Назначить';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Boot ───────────────────────────────────────────────────────── */
|
/* ── Boot ───────────────────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -277,13 +277,18 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
window.removeStudent = async function (id, name) {
|
window.removeStudent = async function (id, name) {
|
||||||
if (!confirm(`Убрать «${name}» из списка «Мои ученики»?\n\nСозданные задания не удалятся.`)) return;
|
const ok = await LS.confirm(
|
||||||
|
`Убрать «${name}» из списка?\nСозданные задания не удалятся — ученик продолжит их видеть.`,
|
||||||
|
{ title: 'Убрать ученика', confirmText: 'Убрать', danger: true }
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
await LS.api('/api/teacher-students/' + id, { method: 'DELETE' });
|
await LS.api('/api/teacher-students/' + id, { method: 'DELETE' });
|
||||||
students = students.filter(s => s.id !== id);
|
students = students.filter(s => s.id !== id);
|
||||||
render();
|
render();
|
||||||
|
LS.toast(`«${name}» убран из списка`, 'success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Ошибка: ' + e.message);
|
LS.toast('Ошибка: ' + e.message, 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -498,6 +498,148 @@ function lsConfirm(message, { title = 'Подтверждение', confirmText
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ────────────────────────────────────────────────────────────────────────
|
||||||
|
LS.modal — universal form/content modal
|
||||||
|
Companion to LS.confirm. Use for forms, pickers, editors — anything
|
||||||
|
that's not a simple yes/no confirmation.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
const m = LS.modal({
|
||||||
|
title: 'Назначить чтение',
|
||||||
|
content: htmlString | DOMElement,
|
||||||
|
size: 'sm' | 'md' | 'lg', // 420 / 560 / 720 px
|
||||||
|
actions: [
|
||||||
|
{ label: 'Отмена', onClick: () => m.close() },
|
||||||
|
{ label: 'Назначить', primary: true, onClick: async () => { ... } },
|
||||||
|
],
|
||||||
|
onClose: () => { ... },
|
||||||
|
});
|
||||||
|
// Returns { close, root, setBody, setActions, setError }
|
||||||
|
──────────────────────────────────────────────────────────────────────── */
|
||||||
|
function lsModal({ title = '', content = '', size = 'md', actions = [], onClose, dismissible = true } = {}) {
|
||||||
|
if (!document.getElementById('ls-modal-style')) {
|
||||||
|
const s = document.createElement('style');
|
||||||
|
s.id = 'ls-modal-style';
|
||||||
|
s.textContent = `
|
||||||
|
.ls-mov{position:fixed;inset:0;z-index:9000;display:flex;align-items:flex-start;justify-content:center;
|
||||||
|
padding:60px 20px 20px;background:rgba(15,23,42,0.55);backdrop-filter:blur(8px);
|
||||||
|
opacity:0;transition:opacity .18s ease;overflow-y:auto;}
|
||||||
|
.ls-mov.open{opacity:1;}
|
||||||
|
.ls-mod{background:#fff;border-radius:18px;width:100%;
|
||||||
|
box-shadow:0 24px 80px rgba(15,23,42,0.28);
|
||||||
|
transform:scale(.96) translateY(-12px);transition:transform .22s ease;
|
||||||
|
display:flex;flex-direction:column;max-height:calc(100vh - 80px);}
|
||||||
|
.ls-mod.sm{max-width:420px;} .ls-mod.md{max-width:560px;} .ls-mod.lg{max-width:720px;}
|
||||||
|
.ls-mov.open .ls-mod{transform:scale(1) translateY(0);}
|
||||||
|
.ls-mod-hdr{display:flex;align-items:center;justify-content:space-between;
|
||||||
|
padding:18px 22px 14px;border-bottom:1px solid rgba(15,23,42,.08);flex-shrink:0;}
|
||||||
|
.ls-mod-title{font-family:'Unbounded',sans-serif;font-size:1rem;font-weight:800;color:#0F172A;}
|
||||||
|
.ls-mod-x{width:32px;height:32px;border:none;background:transparent;color:#56687A;
|
||||||
|
cursor:pointer;border-radius:8px;display:flex;align-items:center;justify-content:center;
|
||||||
|
transition:background .12s;flex-shrink:0;margin-left:12px;}
|
||||||
|
.ls-mod-x:hover{background:rgba(15,23,42,.06);color:#0F172A;}
|
||||||
|
.ls-mod-x svg{width:18px;height:18px;}
|
||||||
|
.ls-mod-body{padding:18px 22px;overflow-y:auto;flex:1;}
|
||||||
|
.ls-mod-err{margin:0 22px 14px;padding:9px 12px;border-radius:8px;font-size:.84rem;
|
||||||
|
background:rgba(241,91,68,.1);border:1px solid rgba(241,91,68,.3);color:#F94144;display:none;}
|
||||||
|
.ls-mod-err.visible{display:block;}
|
||||||
|
.ls-mod-act{display:flex;gap:10px;justify-content:flex-end;
|
||||||
|
padding:14px 22px 18px;border-top:1px solid rgba(15,23,42,.08);flex-shrink:0;}
|
||||||
|
.ls-mod-btn{padding:9px 18px;border-radius:10px;border:1.5px solid rgba(15,23,42,0.18);
|
||||||
|
background:transparent;color:#0F172A;font-family:'Manrope',sans-serif;
|
||||||
|
font-size:.88rem;font-weight:700;cursor:pointer;transition:all .15s;}
|
||||||
|
.ls-mod-btn:hover{border-color:#9B5DE5;color:#9B5DE5;}
|
||||||
|
.ls-mod-btn.primary{background:#9B5DE5;border-color:#9B5DE5;color:#fff;}
|
||||||
|
.ls-mod-btn.primary:hover{background:#7e3eca;border-color:#7e3eca;color:#fff;}
|
||||||
|
.ls-mod-btn.danger{background:#F94144;border-color:#F94144;color:#fff;}
|
||||||
|
.ls-mod-btn.danger:hover{background:#d62a2d;border-color:#d62a2d;}
|
||||||
|
.ls-mod-btn:disabled{opacity:.55;cursor:not-allowed;}
|
||||||
|
@media (max-width:540px){.ls-mov{padding:20px 12px;}.ls-mod-hdr,.ls-mod-body,.ls-mod-act{padding-left:16px;padding-right:16px;}}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevFocus = document.activeElement;
|
||||||
|
const ov = document.createElement('div');
|
||||||
|
ov.className = 'ls-mov';
|
||||||
|
ov.setAttribute('role', 'dialog');
|
||||||
|
ov.setAttribute('aria-modal', 'true');
|
||||||
|
ov.innerHTML = `
|
||||||
|
<div class="ls-mod ${size}" onclick="event.stopPropagation()">
|
||||||
|
<div class="ls-mod-hdr">
|
||||||
|
<div class="ls-mod-title"></div>
|
||||||
|
<button class="ls-mod-x" aria-label="Закрыть">${lsIcon('x-close', 18)}</button>
|
||||||
|
</div>
|
||||||
|
<div class="ls-mod-body"></div>
|
||||||
|
<div class="ls-mod-err"></div>
|
||||||
|
<div class="ls-mod-act"></div>
|
||||||
|
</div>`;
|
||||||
|
ov.querySelector('.ls-mod-title').textContent = title;
|
||||||
|
const bodyEl = ov.querySelector('.ls-mod-body');
|
||||||
|
const errEl = ov.querySelector('.ls-mod-err');
|
||||||
|
const actEl = ov.querySelector('.ls-mod-act');
|
||||||
|
|
||||||
|
function setBody(c) {
|
||||||
|
bodyEl.innerHTML = '';
|
||||||
|
if (typeof c === 'string') bodyEl.innerHTML = c;
|
||||||
|
else if (c instanceof Node) bodyEl.appendChild(c);
|
||||||
|
}
|
||||||
|
function setError(msg) {
|
||||||
|
if (!msg) { errEl.classList.remove('visible'); return; }
|
||||||
|
errEl.textContent = msg;
|
||||||
|
errEl.classList.add('visible');
|
||||||
|
}
|
||||||
|
function setActions(arr) {
|
||||||
|
actEl.innerHTML = '';
|
||||||
|
(arr || []).forEach((a, i) => {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.className = 'ls-mod-btn' + (a.primary ? ' primary' : '') + (a.danger ? ' danger' : '');
|
||||||
|
b.textContent = a.label || (a.primary ? 'OK' : 'Отмена');
|
||||||
|
if (a.id) b.id = a.id;
|
||||||
|
b.addEventListener('click', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (typeof a.onClick === 'function') a.onClick();
|
||||||
|
else if (a.close !== false) close();
|
||||||
|
});
|
||||||
|
actEl.appendChild(b);
|
||||||
|
});
|
||||||
|
actEl.style.display = (arr && arr.length) ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
setBody(content);
|
||||||
|
setActions(actions);
|
||||||
|
document.body.appendChild(ov);
|
||||||
|
requestAnimationFrame(() => ov.classList.add('open'));
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
ov.classList.remove('open');
|
||||||
|
setTimeout(() => {
|
||||||
|
ov.remove();
|
||||||
|
prevFocus?.focus?.();
|
||||||
|
if (typeof onClose === 'function') onClose();
|
||||||
|
}, 230);
|
||||||
|
}
|
||||||
|
|
||||||
|
ov.querySelector('.ls-mod-x').onclick = () => { if (dismissible) close(); };
|
||||||
|
if (dismissible) {
|
||||||
|
ov.addEventListener('click', e => { if (e.target === ov) close(); });
|
||||||
|
}
|
||||||
|
const onKey = e => {
|
||||||
|
if (e.key === 'Escape' && dismissible) { e.preventDefault(); close(); }
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
const _close = close;
|
||||||
|
close = () => { document.removeEventListener('keydown', onKey); _close(); };
|
||||||
|
|
||||||
|
// Focus first focusable element inside body, or close button
|
||||||
|
setTimeout(() => {
|
||||||
|
const focusable = bodyEl.querySelector('input,select,textarea,button') || ov.querySelector('.ls-mod-x');
|
||||||
|
focusable?.focus?.();
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
return { close, root: ov, body: bodyEl, setBody, setActions, setError };
|
||||||
|
}
|
||||||
|
|
||||||
/* ── applyRoleSidebar — reveal teacher/admin sidebar items ───────────── */
|
/* ── applyRoleSidebar — reveal teacher/admin sidebar items ───────────── */
|
||||||
function applyRoleSidebar(user) {
|
function applyRoleSidebar(user) {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
@@ -775,6 +917,7 @@ window.LS = {
|
|||||||
applyRoleSidebar,
|
applyRoleSidebar,
|
||||||
icon: lsIcon,
|
icon: lsIcon,
|
||||||
confirm: lsConfirm,
|
confirm: lsConfirm,
|
||||||
|
modal: lsModal,
|
||||||
toast: lsToast,
|
toast: lsToast,
|
||||||
skeleton: lsSkeleton,
|
skeleton: lsSkeleton,
|
||||||
api: apiFetch,
|
api: apiFetch,
|
||||||
|
|||||||
Reference in New Issue
Block a user