Add a focus trap for overlays, and close them with Esc

This commit is contained in:
Eevee (Evelyn Woods) 2021-01-08 22:00:59 -07:00
parent 246ef468de
commit e64a553365

View File

@ -33,22 +33,31 @@ export class Overlay {
constructor(conductor, root) { constructor(conductor, root) {
this.conductor = conductor; this.conductor = conductor;
this.root = root; this.root = root;
// Make the dialog itself focusable; this makes a lot of stuff easier, like ensuring that
// pressing Esc always has a viable target
this.root.tabIndex = 0;
// Don't propagate clicks on the root element, so they won't trigger a // Don't propagate clicks on the root element, so they won't trigger a parent overlay's
// parent overlay's automatic dismissal // automatic dismissal
this.root.addEventListener('click', ev => { this.root.addEventListener('click', ev => {
ev.stopPropagation(); ev.stopPropagation();
}); });
// Block any window-level key handlers from firing when we type // Don't propagate keys, either. This is only a partial solution (for when something within
// the dialog has focus), but open() adds another handler to block keys more aggressively
this.root.addEventListener('keydown', ev => { this.root.addEventListener('keydown', ev => {
ev.stopPropagation(); ev.stopPropagation();
if (ev.key === 'Escape') {
this.close();
}
}); });
} }
open() { open() {
// FIXME ah, but keystrokes can still go to the game, including if (this.root.isConnected) {
// spacebar to begin it if it was waiting. how do i completely disable this.close();
// an entire chunk of the page? }
if (this.conductor.player.state === 'playing') { if (this.conductor.player.state === 'playing') {
this.conductor.player.set_state('paused'); this.conductor.player.set_state('paused');
} }
@ -61,10 +70,76 @@ export class Overlay {
this.close(); this.close();
}); });
// Start with the overlay itself focused
this.root.focus();
// While this dialog is open, keys should not reach the rest of the document, and you should
// not be able to tab your way out of it. This is a rough implementation of that.
// Note that focusin bubbles, but focus doesn't. Also, focusin happens /just before/ an
// element receives focus, not afterwards, but that doesn't seem to affect this.
this.focusin_handler = ev => {
// If we're no longer visible at all, remove this event handler
if (! this.root.isConnected) {
this._remove_global_event_handlers();
return;
}
// If we're not the topmost overlay, do nothing
if (this.root.parentNode.nextElementSibling)
return;
// No problem if the focus is within the dialog, OR on the root <html> element
if (ev.target === document.documentElement || this.root.contains(ev.target)) {
this.last_focused = ev.target;
return;
}
// Otherwise, focus is trying to escape! Put a stop to that.
// Focus was probably moved with tab or shift-tab. We should be the last element in the
// document, so tabbing off the end of us should go to browser UI. Shift-tabbing back
// beyond the start of a document usually goes to the root (and after that, browser UI
// again). Thus, there are only two common cases here: if the last valid focus was on
// the document root, they must be tabbing forwards, so focus our first element; if the
// last valid focus was within us, they must be tabbing backwards, so focus the root.
if (this.last_focused === document.documentElement) {
this.root.focus();
this.last_focused = this.root;
}
else {
document.documentElement.focus();
this.last_focused = document.documentElement;
}
};
window.addEventListener('focusin', this.focusin_handler);
// Block any keypresses attempting to go to an element outside the dialog
this.keydown_handler = ev => {
// If we're no longer visible at all, remove this event handler
if (! this.root.isConnected) {
this._remove_global_event_handlers();
return;
}
// If we're not the topmost overlay, do nothing
if (this.root.parentNode.nextElementSibling)
return;
// Note that if the target is the window itself, contains() will explode
if (! (ev.target instanceof Node && this.root.contains(ev.target))) {
ev.stopPropagation();
}
};
// Use capture, which runs before any other event handler
window.addEventListener('keydown', this.keydown_handler, true);
return overlay; return overlay;
} }
_remove_global_event_handlers() {
window.removeEventListener('focusin', this.focusin_handler);
window.removeEventListener('keydown', this.keydown_handler, true);
}
close() { close() {
this._remove_global_event_handlers();
this.root.closest('.overlay').remove(); this.root.closest('.overlay').remove();
} }
} }