Offline shell (precache, offline notice) and accessibility layer (skip link, button names, sheet focus and Escape)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
df8c6ce586
commit
4625848048
5 files changed
+81
-7
No files matched your search
@@ -56,5 +56,6 @@
|
|||||||
<script src="/links.js"></script>
|
<script src="/links.js"></script>
|
||||||
<script src="/palette.js"></script>
|
<script src="/palette.js"></script>
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
|
<script src="/shell.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// 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.textContent = 'You are offline. Showing what was saved on this device; changes will not sync until you are back online.';
|
||||||
|
document.body.appendChild(bar);
|
||||||
|
const sync = () => { bar.hidden = navigator.onLine; };
|
||||||
|
addEventListener('online', sync); 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(); }
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -495,3 +495,9 @@ body.lyropen { overflow: hidden; }
|
|||||||
.palrow.on { background: var(--panel2); }
|
.palrow.on { background: var(--panel2); }
|
||||||
.palrow svg { width: 20px; height: 20px; color: var(--muted); }
|
.palrow svg { width: 20px; height: 20px; color: var(--muted); }
|
||||||
@media (prefers-reduced-motion: reduce) { * { animation-duration: .01ms !important; transition-duration: .01ms !important; } }
|
@media (prefers-reduced-motion: reduce) { * { animation-duration: .01ms !important; transition-duration: .01ms !important; } }
|
||||||
|
|
||||||
|
/* Offline notice, skip link, keyboard focus */
|
||||||
|
#offline { position: fixed; left: 0; right: 0; top: 0; z-index: 70; padding: calc(var(--sat) + 6px) 16px 6px; text-align: center; font-size: 13px; background: #b26a00; color: #fff; }
|
||||||
|
.skip { position: fixed; left: 8px; top: -60px; z-index: 80; padding: 10px 14px; border-radius: 10px; background: var(--accent); color: var(--accent-t); }
|
||||||
|
.skip:focus { top: calc(var(--sat) + 8px); }
|
||||||
|
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
+16
-5
@@ -1,11 +1,22 @@
|
|||||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||||
const CACHE = 'hub-v6';
|
const CACHE = 'hub-v7';
|
||||||
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
||||||
|
|
||||||
self.addEventListener('install', e => {
|
// Install: keep the icons, and (when signed in) the page itself with the scripts and styles it names, so the app opens offline.
|
||||||
e.waitUntil(caches.open(CACHE).then(c => c.addAll(SHELL)));
|
async function precache() {
|
||||||
self.skipWaiting();
|
const c = await caches.open(CACHE);
|
||||||
});
|
await c.addAll(SHELL);
|
||||||
|
try {
|
||||||
|
const r = await fetch('/', { credentials: 'same-origin', redirect: 'manual' });
|
||||||
|
if (r.ok) {
|
||||||
|
const html = await r.clone().text();
|
||||||
|
await c.put('/', r);
|
||||||
|
const urls = [...html.matchAll(/(?:src|href)="(\/[^"]+\.(?:js|css)(?:\?[^"]*)?)"/g)].map(m => m[1]);
|
||||||
|
await Promise.all(urls.map(u => c.add(u).catch(() => {})));
|
||||||
|
}
|
||||||
|
} catch { /* signed out or offline: it is cached the first time it is used */ }
|
||||||
|
}
|
||||||
|
self.addEventListener('install', e => { e.waitUntil(precache()); self.skipWaiting(); });
|
||||||
self.addEventListener('activate', e => {
|
self.addEventListener('activate', e => {
|
||||||
e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== CACHE).map(k => caches.delete(k)))));
|
e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== CACHE).map(k => caches.delete(k)))));
|
||||||
self.clients.claim();
|
self.clients.claim();
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ function serveStatic(req, res) {
|
|||||||
if (err) { res.writeHead(404); return res.end('Not found'); }
|
if (err) { res.writeHead(404); return res.end('Not found'); }
|
||||||
// The pages link to /app.js?v=<build> etc. A new release changes the address, so no proxy cache ("Cache Assets" in
|
// The pages link to /app.js?v=<build> etc. A new release changes the address, so no proxy cache ("Cache Assets" in
|
||||||
// Nginx Proxy Manager, a CDN, the browser) can hand out an old copy.
|
// Nginx Proxy Manager, a CDN, the browser) can hand out an old copy.
|
||||||
if (p === '/index.html' || p === '/login.html') data = Buffer.from(String(data).replace(/(src|href)="(\/(?:app|webauthn|login|links|palette)\.js|\/style\.css)"/g, `$1="$2?v=${BUILD}"`));
|
if (p === '/index.html' || p === '/login.html') data = Buffer.from(String(data).replace(/(src|href)="(\/(?:app|webauthn|login|links|palette|shell)\.js|\/style\.css)"/g, `$1="$2?v=${BUILD}"`));
|
||||||
const headers = { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' };
|
const headers = { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' };
|
||||||
if (p.startsWith('/icons/')) headers['cache-control'] = 'public, max-age=604800'; // icons and splash screens rarely change
|
if (p.startsWith('/icons/')) headers['cache-control'] = 'public, max-age=604800'; // icons and splash screens rarely change
|
||||||
else { // everything else: the browser may keep a copy but must check with us first, so a new version is never missed
|
else { // everything else: the browser may keep a copy but must check with us first, so a new version is never missed
|
||||||
@@ -113,7 +113,7 @@ function serveStatic(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Changes whenever the app's files change, so an open page can tell that a newer version is available.
|
// Changes whenever the app's files change, so an open page can tell that a newer version is available.
|
||||||
const BUILD = (() => { try { return crypto.createHash('sha1').update(['app.js', 'links.js', 'palette.js', 'index.html', 'style.css', 'webauthn.js', 'login.js', 'login.html', 'sw.js'].map(f => fs.readFileSync(path.join(PUBLIC, f))).join('')).digest('hex').slice(0, 12); } catch { return String(Date.now()); } })();
|
const BUILD = (() => { try { return crypto.createHash('sha1').update(['app.js', 'links.js', 'palette.js', 'shell.js', 'index.html', 'style.css', 'webauthn.js', 'login.js', 'login.html', 'sw.js'].map(f => fs.readFileSync(path.join(PUBLIC, f))).join('')).digest('hex').slice(0, 12); } catch { return String(Date.now()); } })();
|
||||||
|
|
||||||
// ---- OnlyOffice in-app editing (JWT-signed editor config + save-back callback to Seafile)
|
// ---- OnlyOffice in-app editing (JWT-signed editor config + save-back callback to Seafile)
|
||||||
const JWT_SECRET = process.env.ONLYOFFICE_JWT_SECRET || '';
|
const JWT_SECRET = process.env.ONLYOFFICE_JWT_SECRET || '';
|
||||||
|
|||||||
Reference in new issue
Block a user