From e64a5533653990ccb8ef3df8d411227890e47fa6 Mon Sep 17 00:00:00 2001 From: "Eevee (Evelyn Woods)" Date: Fri, 8 Jan 2021 22:00:59 -0700 Subject: [PATCH] Add a focus trap for overlays, and close them with Esc --- js/main-base.js | 87 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 81 insertions(+), 6 deletions(-) diff --git a/js/main-base.js b/js/main-base.js index 5e7e47a..7495e86 100644 --- a/js/main-base.js +++ b/js/main-base.js @@ -33,22 +33,31 @@ export class Overlay { constructor(conductor, root) { this.conductor = conductor; 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 - // parent overlay's automatic dismissal + // Don't propagate clicks on the root element, so they won't trigger a parent overlay's + // automatic dismissal this.root.addEventListener('click', ev => { 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 => { ev.stopPropagation(); + + if (ev.key === 'Escape') { + this.close(); + } }); } open() { - // FIXME ah, but keystrokes can still go to the game, including - // spacebar to begin it if it was waiting. how do i completely disable - // an entire chunk of the page? + if (this.root.isConnected) { + this.close(); + } + if (this.conductor.player.state === 'playing') { this.conductor.player.set_state('paused'); } @@ -61,10 +70,76 @@ export class Overlay { 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 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; } + _remove_global_event_handlers() { + window.removeEventListener('focusin', this.focusin_handler); + window.removeEventListener('keydown', this.keydown_handler, true); + } + close() { + this._remove_global_event_handlers(); this.root.closest('.overlay').remove(); } }