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 <body>: 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 <body>.
This commit is contained in:
jolavillette 2026-08-26 17:42:48 +02:00
parent f716a61298
commit e9cf4e74f3
2 changed files with 34 additions and 4 deletions

View File

@ -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 <body> 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 <body> 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 }, '');

View File

@ -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'),
])),
];
},