Skip to content

Commit 9c140c4

Browse files
committed
(kb) introduce "regions" keyboard navigation with RegionFocusSwitcher
RegionFocusSwitcher makes it possible for the user to cycle through app panels and document widgets (named "regions" as a whole) with one keyboard shortcut. 1) On a grist document page, pressing the shortcut jumps your keyboard focus from widget to widget, but also panel to panel. You go to widget 1, widget 2, then left panel, top panel, then widget 1, widget 2, etc. This shortcut is necessary to reach stuff outside widgets: because by default, usual kb shortcuts like tab or arrow keys are highjacked by widgets. On other pages (that don't have widgets), pressing the shortcut jumps your keyboard focus from panel to panel, but this time it also includes the main content area as a "panel". The shortcut is not _that_ necessary on other pages since you can now use Tab normally. But I guess it's better to keep the shortcut always work whatever the page. 2) The new creatorPanel command allows to keyboard focus from/to the creator panel. This is a separate command from the regions-cycling, as the creator panel is tied to the currently focused viewLayout section. So we must be able to focus a section, and then directly go to the creator panel in order to configure it. If we notice the user just spammed the Ctrl+O shortcut too much, we assume he just tried accessing the creator panel and we show him the keyboard shortcut. 3) View now keeps track of this new stuff, in order to disable the viewSection commands when focusing panels. This is an important trick that lets us enable the Tab key and others when focusing the panels. 4) The new navigation shortcuts are now always on, even in inputs. Using ctrl+o is somewhat problematic because it is the default browser shortcut for opening files, so in cases where we don't catch the keypress, the filechooser appears. We want to avoid that as much as possible. 5) The main trick allowing us to easily allow Tab navigation on panels, while on a grist docs page, is the change in Clipboard.js. We can now set a 'clipboard_group_focus' class somewhere in the dom, and the clipboard will allow focusing elements on elements inside the one having the class. This is not done yet: - needs more actual usage testing to finetune the behavior and make sure it is what we want. - I need to check every page using the `pagePanels` function, as a new div wraps the content and may make the layout buggy - needs tests
1 parent 1b971b2 commit 9c140c4

21 files changed

+732
-56
lines changed

app/client/components/BaseView.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ function BaseView(gristDoc, viewSectionModel, options) {
128128
this.listenTo(this.viewSection.events, 'rowHeightChange', this.onResize );
129129

130130
// Create a command group for keyboard shortcuts common to all views.
131-
this.autoDispose(commands.createGroup(BaseView.commonCommands, this, this.viewSection.hasFocus));
131+
this.autoDispose(commands.createGroup(BaseView.commonCommands, this, this.viewSection.enableCommands));
132132

133133
//--------------------------------------------------
134134
// Prepare logic for linking with other sections.

app/client/components/Clipboard.js

+42-13
Original file line numberDiff line numberDiff line change
@@ -73,14 +73,7 @@ function Clipboard(app) {
7373

7474
FocusLayer.create(this, {
7575
defaultFocusElem: this.copypasteField,
76-
allowFocus: (element) => {
77-
// We always allow focus if current screen doesn't have any clipboard events registered.
78-
// This basically means the focus grab is disabled if no clipboard command is active.
79-
const { copy, cut, paste } = commands.allCommands;
80-
return (copy._activeFunc === _.noop && cut._activeFunc === _.noop && paste._activeFunc === _.noop)
81-
? true
82-
: allowFocus(element);
83-
},
76+
allowFocus,
8477
onDefaultFocus: () => {
8578
this.copypasteField.value = ' ';
8679
this.copypasteField.select();
@@ -303,18 +296,54 @@ async function getTextFromClipboardItem(clipboardItem, type) {
303296
}
304297

305298
/**
306-
* Helper to determine if the currently active element deserves to keep its own focus, and capture
307-
* copy-paste events. Besides inputs and textareas, any element can be marked to be a valid
308-
* copy-paste target by adding 'clipboard_focus' class to it.
299+
* Helper to determine if the currently active element deserves to keep its own focus, and capture copy-paste events.
300+
*
301+
* By default, focus is automatically allowed if:
302+
* - there is no clipboard commands registered,
303+
* - the element is an input, textarea, select or iframe,
304+
* - the element has a tabindex attribute
305+
*
306+
* You can explicitly allow focus by setting different classes:
307+
* - using the 'clipboard_allow_focus' class will allow focusing the element having the class,
308+
* - using the 'clipboard_allow_group_focus' class will allow focusing any descendant element of the one having the class
309+
*
310+
* You can explicitly forbid focus by setting the 'clipboard_forbid_focus' class on a element. Forbidding wins over allowing
311+
* if both are set.
309312
*/
310313
function allowFocus(elem) {
311-
return elem && (FOCUS_TARGET_TAGS.hasOwnProperty(elem.tagName) ||
314+
const {copy, cut, paste} = commands.allCommands;
315+
const noCopyPasteCommands = copy._activeFunc === _.noop && cut._activeFunc === _.noop && paste._activeFunc === _.noop;
316+
if (elem && elem.classList.contains('clipboard_forbid_focus')) {
317+
return false;
318+
}
319+
if (noCopyPasteCommands) {
320+
return true;
321+
}
322+
if (elem && elem.closest('.clipboard_group_focus')) {
323+
return true;
324+
}
325+
const allow = elem && (
326+
FOCUS_TARGET_TAGS.hasOwnProperty(elem.tagName) ||
312327
elem.hasAttribute("tabindex") ||
313-
elem.classList.contains('clipboard_focus'));
328+
elem.classList.contains('clipboard_allow_focus')
329+
);
330+
return allow;
314331
}
315332

316333
Clipboard.allowFocus = allowFocus;
317334

335+
/**
336+
* Helper to manually refocus the main app focus grab element.
337+
*/
338+
function triggerFocusGrab() {
339+
const elem = document.querySelector('textarea.copypaste.mousetrap');
340+
if (elem) {
341+
elem.focus();
342+
}
343+
}
344+
345+
Clipboard.triggerFocusGrab = triggerFocusGrab;
346+
318347
function showUnavailableMenuCommandModal(action) {
319348
let keys;
320349
switch (action) {

app/client/components/Cursor.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ export class Cursor extends Disposable {
112112

113113
this.fieldIndex = baseView.viewSection.viewFields().makeLiveIndex(optCursorPos.fieldIndex || 0);
114114

115-
this.autoDispose(commands.createGroup(Cursor.editorCommands, this, baseView.viewSection.hasFocus));
115+
this.autoDispose(commands.createGroup(Cursor.editorCommands, this, baseView.viewSection.enableCommands));
116116

117117
// RowId might diverge from the one stored in _rowId when the data changes (it is filtered out). So here
118118
// we will calculate rowId based on rowIndex (so in reverse order), to have a proper value.

app/client/components/CustomView.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ export class CustomView extends Disposable {
127127

128128
this.autoDispose(this.customDef.pluginId.subscribe(this._updatePluginInstance, this));
129129
this.autoDispose(this.customDef.sectionId.subscribe(this._updateCustomSection, this));
130-
this.autoDispose(commands.createGroup(CustomView._commands, this, this.viewSection.hasFocus));
130+
this.autoDispose(commands.createGroup(CustomView._commands, this, this.viewSection.enableCommands));
131131

132132
this._hasUnmappedColumns = this.autoDispose(ko.pureComputed(() => {
133133
const columns = this.viewSection.columnsToMap();

app/client/components/DetailView.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,8 @@ function DetailView(gristDoc, viewSectionModel) {
119119

120120
//--------------------------------------------------
121121
// Instantiate CommandGroups for the different modes.
122-
this.autoDispose(commands.createGroup(DetailView.generalCommands, this, this.viewSection.hasFocus));
123-
this.autoDispose(commands.createGroup(DetailView.fieldCommands, this, this.viewSection.hasFocus));
122+
this.autoDispose(commands.createGroup(DetailView.generalCommands, this, this.viewSection.enableCommands));
123+
this.autoDispose(commands.createGroup(DetailView.fieldCommands, this, this.viewSection.enableCommands));
124124
const hasSelection = this.autoDispose(ko.pureComputed(() =>
125125
!this.cellSelector.isCurrentSelectType('') || this.copySelection()));
126126
this.autoDispose(commands.createGroup(DetailView.selectionCommands, this, hasSelection));

app/client/components/Forms/FormView.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ export class FormView extends Disposable {
348348
editField: keyboardActions.edit,
349349
deleteFields: keyboardActions.clearValues,
350350
hideFields: keyboardActions.hideFields,
351-
}, this, this.viewSection.hasFocus));
351+
}, this, this.viewSection.enableCommands));
352352

353353
this._previewUrl = Computed.create(this, use => {
354354
const doc = use(this.gristDoc.docPageModel.currentDoc);

app/client/components/GridView.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
261261

262262
//--------------------------------------------------
263263
// Command group implementing all grid level commands (except cancel)
264-
this.autoDispose(commands.createGroup(GridView.gridCommands, this, this.viewSection.hasFocus));
264+
this.autoDispose(commands.createGroup(GridView.gridCommands, this, this.viewSection.enableCommands));
265265
// Cancel command is registered conditionally, only when there is an active
266266
// cell selection. This command is also used by Raw Data Views, to close the Grid popup.
267267
const hasSelection = this.autoDispose(ko.pureComputed(() =>

0 commit comments

Comments
 (0)