From e9cf4e74f3479e5550c7e73c18028e8271219148 Mon Sep 17 00:00:00 2001 From: jolavillette Date: Wed, 26 Aug 2026 17:42:48 +0200 Subject: [PATCH] webui v154: the image preview claims to be a modal, so let it behave like one The overlay carries role=dialog and aria-modal=true, but its Escape handler sits on the overlay itself and therefore only sees what bubbles through it. The close button is focused on open, so Escape works -- until the first tap on the picture, which moves the focus to : from then on the only way out is the close button or the Back button. The handler moves to the document, in the capture phase, and is removed when the preview closes. Tab is caught there too: aria-modal promises the rest of the page is inert, and without it the focus walks straight out of the preview into the message list behind it. Closing also puts the focus back on whatever opened the preview rather than dropping it on . --- webui-src/app/chat/chat_state.js | 34 ++++++++++++++++++++++++++++++-- webui-src/app/main.js | 4 ++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/webui-src/app/chat/chat_state.js b/webui-src/app/chat/chat_state.js index 6a9f1c7..108bc51 100644 --- a/webui-src/app/chat/chat_state.js +++ b/webui-src/app/chat/chat_state.js @@ -121,13 +121,25 @@ function isEmbeddedImageSrc(src) { // strand embedded browsers such as Android WebView without a usable Back entry. let chatImageViewer = null; let chatImageViewerPreviousOverflow = ''; +let chatImageViewerOpener = null; +let chatImageViewerKeyHandler = null; const CHAT_IMAGE_VIEWER_HISTORY_KEY = 'chatImageViewer'; function removeChatImageViewer() { if (!chatImageViewer) return; + if (chatImageViewerKeyHandler) { + document.removeEventListener('keydown', chatImageViewerKeyHandler, true); + chatImageViewerKeyHandler = null; + } chatImageViewer.remove(); chatImageViewer = null; document.body.style.overflow = chatImageViewerPreviousOverflow; + // Put the focus back where it was taken from, so closing the preview does + // not leave the caret on with the message list scrolled away. + if (chatImageViewerOpener && document.contains(chatImageViewerOpener)) { + chatImageViewerOpener.focus(); + } + chatImageViewerOpener = null; } function closeChatImageViewer() { @@ -168,12 +180,30 @@ function openChatImageViewer(src) { overlay.onclick = (event) => { if (event.target === overlay) closeChatImageViewer(); }; - overlay.onkeydown = (event) => { - if (event.key === 'Escape') closeChatImageViewer(); + + // The overlay says role=dialog and aria-modal=true, so it has to behave like + // one. Listening on the overlay only caught what bubbled through it: tapping + // the picture moves the focus to and Escape went dead from then on. + // Listening on the document, in the capture phase, means Escape closes the + // preview wherever the focus has drifted, and Tab cannot walk out of it into + // the page underneath -- the close button is the only thing to land on. + chatImageViewerKeyHandler = (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + closeChatImageViewer(); + } else if (event.key === 'Tab') { + event.preventDefault(); + closeButton.focus(); + } }; + document.addEventListener('keydown', chatImageViewerKeyHandler, true); chatImageViewerPreviousOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; + // Captured before the overlay steals the focus, and restored on close. + chatImageViewerOpener = document.activeElement instanceof HTMLElement + ? document.activeElement + : null; document.body.appendChild(overlay); chatImageViewer = overlay; history.pushState({ ...(history.state || {}), [CHAT_IMAGE_VIEWER_HISTORY_KEY]: true }, ''); diff --git a/webui-src/app/main.js b/webui-src/app/main.js index 4b87b26..14abeae 100644 --- a/webui-src/app/main.js +++ b/webui-src/app/main.js @@ -116,7 +116,7 @@ const navbar = () => { ? 'Connected to RetroShare Core' : 'Connection Lost', }), - m('span.webui-version', { style: { fontSize: '0.7em' } }, 'v153'), + m('span.webui-version', { style: { fontSize: '0.7em' } }, 'v154'), m('i.fas.fa-sync-alt.refresh-icon', { style: { cursor: 'pointer', fontSize: '0.8em' }, onclick: () => window.location.reload(true), @@ -239,7 +239,7 @@ const MobileStatus = () => { m('small', statusbar.formatBytes(state.totalOut)), ]), ]), - m('.mobile-status-sheet__version', 'WebUI v153'), + m('.mobile-status-sheet__version', 'WebUI v154'), ])), ]; },