(core) Improve focus and keyboard shortcuts in modals.

Summary:
- Factor out focusing logic from Clipboard to FocusLayer.
- Generalize FocusLayer to support adding a temporary layer while a modal is open.
- Stop Mousetrap shortcuts while a modal is open.
- Refactor how Mousetrap's custom stopCallback is implemented to avoid
  needing to bundle knockout for mousetrap.

Test Plan: Added a test that Enter in a UserManager doesn't open a cell editor from underneath the modal.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D2626
This commit is contained in:
Dmitry S
2020-10-02 15:26:14 -04:00
parent 1654a2681f
commit 90db5020c9
6 changed files with 224 additions and 81 deletions

View File

@@ -32,10 +32,10 @@
/* global window, document, $ */
/* global window, document */
var ko = require('knockout');
var {tsvDecode} = require('app/common/tsvFormat');
var {FocusLayer} = require('app/client/lib/FocusLayer');
var commands = require('./commands');
var dom = require('../lib/dom');
@@ -48,9 +48,6 @@ function Clipboard(app) {
this.copypasteField = this.autoDispose(dom('textarea.copypaste.mousetrap', ''));
this.timeoutId = null;
this.onEvent(window, 'focus', this.grabFocus);
this.onEvent(this.copypasteField, 'blur', this.grabFocus);
this.onEvent(this.copypasteField, 'input', function(elem, event) {
var value = elem.value;
elem.value = '';
@@ -62,34 +59,29 @@ function Clipboard(app) {
this.onEvent(this.copypasteField, 'paste', this._onPaste);
document.body.appendChild(this.copypasteField);
this.grabFocus();
// The following block of code deals with what happens when the window is in the background.
// When it is, focus and blur events are unreliable, and we'll watch explicitly for events which
// may cause a change in focus. These wouldn't happen normally for a background window, but do
// happen in Selenium Webdriver testing.
var grabber = this.grabFocus.bind(this);
function setBackgroundCapture(onOff) {
var addRemove = onOff ? window.addEventListener : window.removeEventListener;
// Note the third argument useCapture=true, which lets us notice these events before other
// code that might call .stopPropagation on them.
addRemove.call(window, 'click', grabber, true);
addRemove.call(window, 'mousedown', grabber, true);
addRemove.call(window, 'keydown', grabber, true);
}
this.onEvent(window, 'blur', setBackgroundCapture.bind(null, true));
this.onEvent(window, 'focus', setBackgroundCapture.bind(null, false));
setBackgroundCapture(!document.hasFocus());
FocusLayer.create(this, {
defaultFocusElem: this.copypasteField,
allowFocus: isCopyPasteTarget,
onDefaultFocus: () => {
this.copypasteField.value = ' ';
this.copypasteField.select();
this._app.trigger('clipboard_focus');
},
onDefaultBlur: () => {
this._app.trigger('clipboard_blur');
},
});
// Expose the grabber as a global to allow upload from tests to explicitly restore focus
window.gristClipboardGrabFocus = grabber;
window.gristClipboardGrabFocus = () => FocusLayer.grabFocus();
// Some bugs may prevent Clipboard from re-grabbing focus. To limit the impact of such bugs on
// the user, recover from a bad state in mousedown events. (At the moment of this comment, all
// such known bugs are fixed.)
this.onEvent(window, 'mousedown', (ev) => {
if (!document.activeElement || document.activeElement === document.body) {
this.grabFocus();
FocusLayer.grabFocus();
}
});
@@ -180,24 +172,6 @@ Clipboard.prototype._onPaste = function(elem, event) {
this._cutCallback = null;
};
/**
* Helper to watch a focused element to lose focus, in which point the Clipboard will grab it.
* Because elements getting removed from the DOM don't always trigger 'blur' event, this also
* watches for the element getting disposed (using ko.removeNode or ko.cleanNode).
*/
Clipboard.prototype._watchElementForBlur = function(elem) {
var self = this;
function done() {
$(elem).off('blur.clipboard');
ko.utils.domNodeDisposal.removeDisposeCallback(elem, done);
self.grabFocus();
}
$(elem).one('blur.clipboard', done);
// TODO We need to add proper integration of grainjs and knockout dom-disposal. Otherwise a
// focused node that's disposed by grainjs will not trigger this knockout disposal callback.
ko.utils.domNodeDisposal.addDisposeCallback(elem, done);
};
var FOCUS_TARGET_TAGS = {
'INPUT': true,
'TEXTAREA': true,
@@ -216,33 +190,4 @@ function isCopyPasteTarget(elem) {
elem.classList.contains('clipboard_focus'));
}
/**
* Select the special copypaste field to capture clipboard events.
*/
Clipboard.prototype.grabFocus = function() {
if (!this.timeoutId) {
var self = this;
this.timeoutId = setTimeout(() => {
if (self.isDisposed()) { return; }
self.timeoutId = null;
if (document.activeElement === self.copypasteField) {
return;
}
// If the window doesn't have focus, don't rush to grab it, or we can interfere with focus
// outside the frame when embedded. We'll grab focus when setBackgroundCapture tells us to.
if (!document.hasFocus()) {
return;
}
if (isCopyPasteTarget(document.activeElement)) {
self._watchElementForBlur(document.activeElement);
self._app.trigger('clipboard_blur');
} else {
self.copypasteField.value = ' ';
self.copypasteField.select();
self._app.trigger('clipboard_focus');
}
}, 0);
}
};
module.exports = Clipboard;

View File

@@ -310,7 +310,7 @@ CommandGroup.prototype._removeGroup = function() {
* See also stopCallback in app/client/lib/Mousetrap.js.
*/
CommandGroup.prototype.attach = dom.inlinable(function(elem) {
ko.utils.domData.set(elem, 'mousetrapCommandGroup', this);
Mousetrap.setCustomStopCallback(elem, (combo) => !this.knownKeys.hasOwnProperty(combo));
});
//----------------------------------------------------------------------