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:
Maxim Dolgolyov
2026-05-16 18:41:27 +03:00
parent b1e645157a
commit bc22715734
4 changed files with 211 additions and 135 deletions
+143
View File
@@ -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 ───────────── */
function applyRoleSidebar(user) {
if (!user) return;
@@ -775,6 +917,7 @@ window.LS = {
applyRoleSidebar,
icon: lsIcon,
confirm: lsConfirm,
modal: lsModal,
toast: lsToast,
skeleton: lsSkeleton,
api: apiFetch,