Files
hub/public/shell.js
T

71 lines
6.0 KiB
JavaScript

// App-shell niceties that do not belong to any one tab: offline notice and an accessibility layer
// (names for icon-only buttons, a skip link, keyboard handling and focus for sheets).
(function () {
// ---- Offline notice
const bar = document.createElement('div');
bar.id = 'offline'; bar.setAttribute('role', 'status'); bar.hidden = true;
bar.innerHTML = 'You are offline. <button type="button" id="offinfo" style="background:none;border:0;color:inherit;text-decoration:underline;font:inherit;cursor:pointer;padding:0 0 0 6px;min-height:0">What still works?</button>';
document.body.appendChild(bar);
const sync = () => { bar.hidden = navigator.onLine; };
bar.querySelector('#offinfo').onclick = () => {
const el = openSheet('You are offline', 'The app opens, and these still work from what this device has saved:', `<ul style="line-height:1.7;padding-left:18px">
<li><b>Mail</b>: your last message lists and every message you have opened. Deleting and replying wait until you are back online.</li>
<li><b>Files</b>: folders you have opened, and the saved list of documents.</li>
<li><b>Calendar, tasks, reminders</b>: what was loaded last time.</li>
<li><b>Music, video, Signal</b>: need the connection (the song or movie streams from your server).</li></ul>
<p class="sub">Nothing is lost: it all catches up when you reconnect.</p><div class="bar"><button class="btn ghost" id="offx" style="flex:1;justify-content:center">Close</button></div>`);
el.querySelector('#offx').onclick = closeSheet;
};
// Back online, or coming back to the app after a while away: refresh what is on screen quietly.
let hiddenAt = 0;
const refresh = () => { try { if (typeof mailPoll === 'function') mailPoll(); if (location.hash.startsWith('#/mail') && typeof mailLoadList === 'function' && typeof mail !== 'undefined' && mail.st && $('#mrows') && !mail.msg) mailLoadList(true); else if (/^#\/(home|planner|calendar|tasks)?$/.test(location.hash) && typeof route === 'function' && !document.getElementById('sheet')) route(); } catch {} };
addEventListener('online', () => { sync(); refresh(); });
document.addEventListener('visibilitychange', () => { if (document.hidden) hiddenAt = Date.now(); else if (hiddenAt && Date.now() - hiddenAt > 60e3 && navigator.onLine) { hiddenAt = 0; refresh(); } });
addEventListener('offline', sync); sync();
// ---- Skip link and landmarks
const skip = document.createElement('a');
skip.href = '#view'; skip.className = 'skip'; skip.textContent = 'Skip to content';
skip.addEventListener('click', e => { e.preventDefault(); const v = document.getElementById('view'); if (v) { v.setAttribute('tabindex', '-1'); v.focus(); } });
document.body.insertBefore(skip, document.body.firstChild);
const nav = document.getElementById('nav'); if (nav && !nav.getAttribute('aria-label')) nav.setAttribute('aria-label', 'Main');
// ---- Give every button or link that has only an icon a name (from its title, tooltip, id or class)
const NAMES = { more: 'More', navcol: 'Collapse the menu', sx: 'Cancel', x: 'Close', close: 'Close', back: 'Back', fwd: 'Forward', menu: 'Menu', play: 'Play', pause: 'Pause', next: 'Next', prev: 'Previous', shuffle: 'Shuffle', repeat: 'Repeat', send: 'Send', clip: 'Attach a file', lyrics: 'Lyrics', full: 'Full screen', refresh: 'Refresh', ext: 'Open in a new tab' };
const named = el => (el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || el.textContent.trim() || el.getAttribute('title') || '');
function label(root) {
root.querySelectorAll('button, a[href], [role="button"]').forEach(el => {
if (named(el)) { if (!el.getAttribute('aria-label') && !el.textContent.trim()) el.setAttribute('aria-label', el.getAttribute('title')); return; }
const key = (el.id || '').replace(/^(btn|b)-?/, '') || [...el.classList].find(c => NAMES[c]) || '';
const l = NAMES[key] || NAMES[el.id] || (el.querySelector('img[alt]') || {}).alt;
if (l) el.setAttribute('aria-label', l);
});
root.querySelectorAll('input:not([type=hidden]):not([type=checkbox]):not([type=radio]), select, textarea').forEach(el => {
if (el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || (el.id && document.querySelector(`label[for="${CSS.escape(el.id)}"]`)) || el.closest('label')) return;
const l = el.getAttribute('placeholder') || el.getAttribute('title'); if (l) el.setAttribute('aria-label', l);
});
}
let pending = false;
new MutationObserver(() => { if (pending) return; pending = true; requestAnimationFrame(() => { pending = false; label(document.body); }); })
.observe(document.body, { childList: true, subtree: true });
label(document.body);
// ---- Sheets (bottom sheets and dialogs): Escape closes, focus moves in and returns, Tab stays inside
let lastFocus = null;
new MutationObserver(() => {
const s = document.getElementById('sheet');
if (s && !s._a11y) { s._a11y = true; lastFocus = document.activeElement; setTimeout(() => { if (!s.contains(document.activeElement)) { const f = s.querySelector('input:not([type=hidden]), textarea, select, button'); if (f) f.focus(); } }, 50); }
if (!s && lastFocus) { try { lastFocus.focus(); } catch {} lastFocus = null; }
}).observe(document.body, { childList: true });
addEventListener('keydown', e => {
const s = document.getElementById('sheet'); if (!s) return;
if (e.key === 'Escape' && typeof closeSheet === 'function') { e.preventDefault(); closeSheet(); return; }
if (e.key !== 'Tab') return;
const f = [...s.querySelectorAll('a[href], button:not([disabled]), input:not([type=hidden]):not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])')].filter(x => x.offsetParent !== null);
if (!f.length) return;
const first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && (document.activeElement === last || !s.contains(document.activeElement))) { e.preventDefault(); first.focus(); }
});
})();