Fixed both mobile sheets using a shared dialog component with accessible names, Escape dismissal, focus trapping, and focus return.

This commit is contained in:
defnax 2026-09-10 17:08:56 +02:00
parent 4f7e0053f4
commit ed1b7f1e3d
4 changed files with 83 additions and 13 deletions

51
webui-src/app/dialog.js Normal file
View File

@ -0,0 +1,51 @@
const m = require('mithril');
// Mount only while open. Native modal dialogs make the rest of the page inert
// and provide dialog semantics; callers own the open state and sheet content.
const Dialog = () => {
let opener;
return {
oncreate: ({ dom }) => {
opener = document.activeElement;
dom.showModal();
},
onremove: ({ dom }) => {
dom.close();
if (opener && opener.isConnected) opener.focus();
},
view: ({ attrs, children }) => m('dialog.accessible-dialog', {
class: attrs.overlayClass,
'aria-label': attrs.label,
'aria-modal': 'true',
oncancel: (event) => {
event.preventDefault();
attrs.onclose();
},
onclick: (event) => {
if (event.target === event.currentTarget) attrs.onclose();
},
onkeydown: (event) => {
if (event.key !== 'Tab') return;
const dialog = event.currentTarget;
const controls = Array.from(dialog.querySelectorAll(
'a[href], button, input, select, textarea, [tabindex], [contenteditable="true"]'
)).filter((element) => element.tabIndex >= 0 &&
!element.matches(':disabled') && !element.closest('[inert]') &&
element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden');
const first = controls[0];
const last = controls[controls.length - 1];
if (!first) {
event.preventDefault();
} else if (event.shiftKey && (document.activeElement === first || !controls.includes(document.activeElement))) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (document.activeElement === last || !controls.includes(document.activeElement))) {
event.preventDefault();
first.focus();
}
},
}, m('div', { class: attrs.sheetClass }, children)),
};
};
module.exports = Dialog;

View File

@ -19,6 +19,7 @@ const config = require('config/config_resolver');
const statistics = require('statistics/statistics');
const debug = require('debug/debug');
const statusbar = require('statusbar');
const Dialog = require('dialog');
const networkState = require('network/network_state');
const peopleState = require('people/people_state');
const { ChatRoomsModel, receiveLobbyChatMessage } = require('chat/chat_state');
@ -245,6 +246,7 @@ const MobileStatus = () => {
m('button.mobile-status-trigger[type=button]', {
'aria-label': `Open connection status. ${summary.label}`,
'aria-expanded': String(isOpen),
'aria-haspopup': 'dialog',
onclick: () => (isOpen = true),
}, [
m('span.mobile-status-trigger__dot', { style: { backgroundColor: summary.color } }),
@ -252,11 +254,12 @@ const MobileStatus = () => {
m('i.fas.fa-chevron-up'),
]),
]),
isOpen && m('.mobile-status-overlay', {
onclick: (event) => {
if (event.target === event.currentTarget) isOpen = false;
},
}, m('.mobile-status-sheet', [
isOpen && m(Dialog, {
label: 'Connection status',
overlayClass: 'mobile-status-overlay',
sheetClass: 'mobile-status-sheet',
onclose: () => (isOpen = false),
}, [
m('.mobile-status-sheet__handle'),
m('.mobile-status-sheet__heading', [
m('div', [
@ -298,7 +301,7 @@ const MobileStatus = () => {
onclick: () => window.location.reload(true),
}, [m('i.fas.fa-sync-alt'), ' Reload']),
]),
])),
]),
];
},
};
@ -315,24 +318,27 @@ const MobileNavigation = () => {
return {
view: () => [
isMoreOpen && m('.mobile-more-overlay', {
onclick: (event) => {
if (event.target === event.currentTarget) isMoreOpen = false;
},
}, m('.mobile-more-sheet', [
isMoreOpen && m(Dialog, {
label: 'More navigation',
overlayClass: 'mobile-more-overlay',
sheetClass: 'mobile-more-sheet',
onclose: () => (isMoreOpen = false),
}, [
m('.mobile-more-sheet__handle'),
m('h3', 'More'),
m('.mobile-more-sheet__links', mobileMoreItems.map((item) => link(item))),
m('.mobile-more-sheet__actions', [
m('button[type=button]', { onclick: () => (isMoreOpen = false) }, 'Close'),
m('button[type=button]', { onclick: () => window.location.reload(true) }, [m('i.fas.fa-sync-alt'), ' Reload']),
m('button[type=button]', { onclick: () => rs.logout() }, [m('i.fas.fa-sign-out-alt'), ' Logout']),
]),
])),
]),
m('nav.mobile-bottom-nav[aria-label=Main navigation]', [
mobileItems.map((item) => link(item, 'mobile-bottom-nav__item')),
m('button.mobile-bottom-nav__item[type=button]', {
class: isMoreOpen || mobileMoreItems.some((item) => item.name === routeName()) ? 'active' : '',
'aria-expanded': String(isMoreOpen),
'aria-haspopup': 'dialog',
onclick: () => (isMoreOpen = !isMoreOpen),
}, [m('i.fas.fa-bars.sidenav-icon'), m('span', 'More')]),
]),

View File

@ -645,3 +645,16 @@
cursor: pointer;
}
}
// Reset native dialog dimensions so mobile overlays still fill the viewport.
dialog.accessible-dialog {
margin: 0;
padding: 0;
border: 0;
width: 100%;
height: 100%;
max-width: none;
max-height: none;
box-sizing: border-box;
}
dialog.accessible-dialog::backdrop { background: transparent; }

File diff suppressed because one or more lines are too long