Skip to content

Improve keyboard navigation #1440

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 20 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4a6f7a3
(kb) allow classic keyboard nav when not viewing a document
manuhabitela Feb 10, 2025
1a3715f
(kb) introduce "regions" keyboard navigation with RegionFocusSwitcher
manuhabitela Feb 11, 2025
d3bd7ff
(kb) cleaner way to toggle panel clipboard group/ignore classes
manuhabitela Mar 25, 2025
b49db5d
(kb) remove logs
manuhabitela Mar 25, 2025
68de731
(kb) fix issue when clicking a non-focusable item inside a region
manuhabitela Mar 28, 2025
523dc78
(kb) handle esc key differently whether we use kb or mouse
manuhabitela Mar 28, 2025
c7333ca
(kb) panels focus: change how we handle mouse clicks
manuhabitela Apr 16, 2025
e9ff098
(kb) make sure panel content is not focusable until we want to
manuhabitela Apr 18, 2025
d2b1dc7
(kb) differenciate global view commands from focused-only commands
manuhabitela Apr 16, 2025
eac1a6d
(kb) highlight focused elements only out of grist doc views
manuhabitela Apr 18, 2025
b3bd850
(kb) trying out something, this needs a followup
manuhabitela Apr 18, 2025
83a0496
(kb) update tests after keyboard region switching work
manuhabitela Feb 18, 2025
59da755
(tests) normalize some function calls to find them quicker in codebase
manuhabitela Apr 18, 2025
a7d68f8
(tests) fix flaky tests when adding columns or pages
manuhabitela Mar 27, 2025
4dae191
(tests) fix import flaky tests (dropdown menus)
manuhabitela Mar 27, 2025
a2b8a6d
(kb) change where we initialize RegionFocusSwitcher
manuhabitela Apr 23, 2025
defe109
(kb) regionFocusSwitcher internals: keep everything in state
manuhabitela Apr 23, 2025
dd5b990
(kb) add a first batch of simple tests for the RegionFocusSwitcher
manuhabitela Apr 24, 2025
fa075c2
(kb) only activate kb nav when actually using the keyboard
manuhabitela May 13, 2025
27d5180
(kb) fix crash when the topAppModel is reloaded
manuhabitela May 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions app/client/components/BaseView.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const {markAsSeen} = require('app/client/models/UserPrefs');
const {buildConfirmDelete, reportUndo} = require('app/client/components/modals');
const {buildReassignModal} = require('app/client/ui/buildReassignModal');
const {MutedError} = require('app/client/models/errors');
const {viewCommands} = require('app/client/components/RegionFocusSwitcher');


/**
Expand Down Expand Up @@ -128,7 +129,8 @@ function BaseView(gristDoc, viewSectionModel, options) {
this.listenTo(this.viewSection.events, 'rowHeightChange', this.onResize );

// Create a command group for keyboard shortcuts common to all views.
this.autoDispose(commands.createGroup(BaseView.commonCommands, this, this.viewSection.hasFocus));
this.autoDispose(commands.createGroup(viewCommands(BaseView.commonCommands, this), this, this.viewSection.hasFocus));
this.autoDispose(commands.createGroup(BaseView.commonFocusedCommands, this, this.viewSection.hasRegionFocus));

//--------------------------------------------------
// Prepare logic for linking with other sections.
Expand Down Expand Up @@ -233,34 +235,43 @@ _.extend(Base.prototype, BackboneEvents);

/**
* These commands are common to GridView and DetailView.
*
* They work when the view is the currently active one.
*/
BaseView.commonCommands = {
input: function(init) {
this.scrollToCursor(true).catch(reportError);
this.activateEditorAtCursor({init});
},
editField: function(event) { closeRegisteredMenu(); this.scrollToCursor(true); this.activateEditorAtCursor({event}); },

copyLink: function() { this.copyLink().catch(reportError); },
filterByThisCellValue: function() { this.filterByThisCellValue(); },
duplicateRows: function() { this._duplicateRows().catch(reportError); },
openDiscussion: function() { this.openDiscussionAtCursor(); },
insertRecordBefore: function() { this.insertRow(this.cursor.rowIndex()); },
insertRecordAfter: function() { this.insertRow(this.cursor.rowIndex() + 1); },
};

/**
* These commands are common to GridView and DetailView.
*
* They are enabled only when the user is actually focusing the view, meaning
* they don't work when the view is the active one but the user is focused on the creator panel.
*/
BaseView.commonFocusedCommands = {
editField: function(event) { closeRegisteredMenu(); this.scrollToCursor(true); this.activateEditorAtCursor({event}); },

insertCurrentDate: function() { this.insertCurrentDate(false); },
insertCurrentDateTime: function() { this.insertCurrentDate(true); },

copyLink: function() { this.copyLink().catch(reportError); },

deleteRecords: function(source) { this.deleteRecords(source); },

filterByThisCellValue: function() { this.filterByThisCellValue(); },
duplicateRows: function() { this._duplicateRows().catch(reportError); },
openDiscussion: function() { this.openDiscussionAtCursor(); },
viewAsCard: function() {
/* Overridden by subclasses.
*
* This is still needed so that <space> doesn't trigger the `input` command
* if a subclass doesn't support opening the current record as a card. */
},
};
}

BaseView.prototype.selectedRows = function() {
return [];
Expand Down Expand Up @@ -860,4 +871,5 @@ BaseView.prototype.isRecordCardDisabled = function() {
return this.viewSection.isTableRecordCardDisabled();
}


module.exports = BaseView;
50 changes: 44 additions & 6 deletions app/client/components/Clipboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ var dom = require('../lib/dom');
var Base = require('./Base');
var tableUtil = require('../lib/tableUtil');

var _ = require('underscore');

const t = makeT('Clipboard');

function Clipboard(app) {
Expand All @@ -71,7 +73,7 @@ function Clipboard(app) {

FocusLayer.create(this, {
defaultFocusElem: this.copypasteField,
allowFocus: allowFocus,
allowFocus,
onDefaultFocus: () => {
this.copypasteField.value = ' ';
this.copypasteField.select();
Expand Down Expand Up @@ -294,18 +296,54 @@ async function getTextFromClipboardItem(clipboardItem, type) {
}

/**
* Helper to determine if the currently active element deserves to keep its own focus, and capture
* copy-paste events. Besides inputs and textareas, any element can be marked to be a valid
* copy-paste target by adding 'clipboard_focus' class to it.
* Helper to determine if the currently active element deserves to keep its own focus, and capture copy-paste events.
*
* By default, focus is automatically allowed if:
* - there is no clipboard commands registered,
* - the element is an input, textarea, select or iframe,
* - the element has a tabindex attribute
*
* You can explicitly allow focus by setting different classes:
* - using the 'clipboard_allow_focus' class will allow focusing the element having the class,
* - using the 'clipboard_allow_group_focus' class will allow focusing any descendant element of the one having the class
*
* You can explicitly forbid focus by setting the 'clipboard_forbid_focus' class on a element. Forbidding wins over allowing
* if both are set.
*/
function allowFocus(elem) {
return elem && (FOCUS_TARGET_TAGS.hasOwnProperty(elem.tagName) ||
const {copy, cut, paste} = commands.allCommands;
const noCopyPasteCommands = copy._activeFunc === _.noop && cut._activeFunc === _.noop && paste._activeFunc === _.noop;
if (elem && elem.classList.contains('clipboard_forbid_focus')) {
return false;
}
if (noCopyPasteCommands) {
return true;
}
Comment on lines +315 to +321
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a maybe too optimistic assumption to make (i'd say it's okay since I wrote it but just putting some lights on this), but what this means is: if we notice we don't have any copy/paste commands registered, we assume we don't have to steal usual browser focus. This is the main thing resulting in the default browser focus being possible on non-document pages.

if (elem && elem.closest('.clipboard_group_focus')) {
return true;
}
const allow = elem && (
FOCUS_TARGET_TAGS.hasOwnProperty(elem.tagName) ||
elem.hasAttribute("tabindex") ||
elem.classList.contains('clipboard_focus'));
elem.classList.contains('clipboard_allow_focus')
);
return allow;
}

Clipboard.allowFocus = allowFocus;

/**
* Helper to manually refocus the main app focus grab element.
*/
function triggerFocusGrab() {
const elem = document.querySelector('textarea.copypaste.mousetrap');
if (elem) {
elem.focus();
}
}

Clipboard.triggerFocusGrab = triggerFocusGrab;

function showUnavailableMenuCommandModal(action) {
let keys;
switch (action) {
Expand Down
2 changes: 1 addition & 1 deletion app/client/components/Cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export class Cursor extends Disposable {

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

this.autoDispose(commands.createGroup(Cursor.editorCommands, this, baseView.viewSection.hasFocus));
this.autoDispose(commands.createGroup(Cursor.editorCommands, this, baseView.viewSection.hasRegionFocus));

// RowId might diverge from the one stored in _rowId when the data changes (it is filtered out). So here
// we will calculate rowId based on rowIndex (so in reverse order), to have a proper value.
Expand Down
2 changes: 1 addition & 1 deletion app/client/components/CustomView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export class CustomView extends Disposable {

this.autoDispose(this.customDef.pluginId.subscribe(this._updatePluginInstance, this));
this.autoDispose(this.customDef.sectionId.subscribe(this._updateCustomSection, this));
this.autoDispose(commands.createGroup(CustomView._commands, this, this.viewSection.hasFocus));
this.autoDispose(commands.createGroup(CustomView._commands, this, this.viewSection.hasRegionFocus));

this._unmappedColumns = this.autoDispose(ko.pureComputed(() => {
const columns = this.viewSection.columnsToMap();
Expand Down
37 changes: 20 additions & 17 deletions app/client/components/DetailView.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const selector = require('./CellSelector');
const {CopySelection} = require('./CopySelection');
const RecordLayout = require('./RecordLayout');
const commands = require('./commands');
const {viewCommands} = require('./RegionFocusSwitcher');
const tableUtil = require('../lib/tableUtil');
const {CardContextMenu} = require('../ui/CardContextMenu');
const {FieldContextMenu} = require('../ui/FieldContextMenu');
Expand Down Expand Up @@ -127,8 +128,8 @@ function DetailView(gristDoc, viewSectionModel) {

//--------------------------------------------------
// Instantiate CommandGroups for the different modes.
this.autoDispose(commands.createGroup(DetailView.generalCommands, this, this.viewSection.hasFocus));
this.autoDispose(commands.createGroup(DetailView.fieldCommands, this, this.viewSection.hasFocus));
this.autoDispose(commands.createGroup(viewCommands(DetailView.detailCommands, this), this, this.viewSection.hasFocus));
this.autoDispose(commands.createGroup(DetailView.detailFocusedCommands, this, this.viewSection.hasRegionFocus));
const hasSelection = this.autoDispose(ko.pureComputed(() =>
!this.cellSelector.isCurrentSelectType('') || this.copySelection()));
this.autoDispose(commands.createGroup(DetailView.selectionCommands, this, hasSelection));
Expand All @@ -154,31 +155,33 @@ DetailView.prototype._updateFloatingRow = function() {
};

/**
* DetailView commands.
* DetailView commands, enabled when the view is the active one.
*/
DetailView.generalCommands = {
cursorUp: function() { this.cursor.fieldIndex(this.cursor.fieldIndex() - 1); },
cursorDown: function() { this.cursor.fieldIndex(this.cursor.fieldIndex() + 1); },
pageUp: function() { this.cursor.rowIndex(this.cursor.rowIndex() - 1); },
pageDown: function() { this.cursor.rowIndex(this.cursor.rowIndex() + 1); },
copy: function() { return this.copy(this.getSelection()); },
cut: function() { return this.cut(this.getSelection()); },
paste: function(pasteObj, cutCallback) {
return this.gristDoc.docData.bundleActions(null, () => this.paste(pasteObj, cutCallback));
},

DetailView.detailCommands = {
editLayout: function() {
if (this.scrolly()) {
this.scrolly().scrollRowIntoView(this.cursor.rowIndex());
}
this.recordLayout.editLayout(this.cursor.rowIndex());
},
hideCardFields: function() { this._hideCardFields(); },
copy: function() { return this.copy(this.getSelection()); },
cut: function() { return this.cut(this.getSelection()); },
paste: function(pasteObj, cutCallback) {
return this.gristDoc.docData.bundleActions(null, () => this.paste(pasteObj, cutCallback));
},
};

DetailView.fieldCommands = {
/**
* DetailView commands, enabled when the view is user-focused.
*/
DetailView.detailFocusedCommands = {
cursorUp: function() { this.cursor.fieldIndex(this.cursor.fieldIndex() - 1); },
cursorDown: function() { this.cursor.fieldIndex(this.cursor.fieldIndex() + 1); },
pageUp: function() { this.cursor.rowIndex(this.cursor.rowIndex() - 1); },
pageDown: function() { this.cursor.rowIndex(this.cursor.rowIndex() + 1); },
clearValues: function() { this._clearCardFields(); },
hideCardFields: function() { this._hideCardFields(); },
};
}

DetailView.selectionCommands = {
clearCopySelection: function() { this._clearCopySelection(); },
Expand Down
2 changes: 1 addition & 1 deletion app/client/components/Forms/FormView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ export class FormView extends Disposable {
editField: keyboardActions.edit,
deleteFields: keyboardActions.clearValues,
hideFields: keyboardActions.hideFields,
}, this, this.viewSection.hasFocus));
}, this, this.viewSection.hasRegionFocus));

this._previewUrl = Computed.create(this, use => {
const doc = use(this.gristDoc.docPageModel.currentDoc);
Expand Down
105 changes: 55 additions & 50 deletions app/client/components/GridView.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const tableUtil = require('../lib/tableUtil');
const {addToSort, sortBy} = require('../lib/sortUtil');

const commands = require('./commands');
const {viewCommands} = require('./RegionFocusSwitcher');
const viewCommon = require('./viewCommon');
const Base = require('./Base');
const BaseView = require('./BaseView');
Expand Down Expand Up @@ -266,8 +267,10 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
this.onEvent(this.scrollPane, 'scroll', this.onScroll);

//--------------------------------------------------
// Command group implementing all grid level commands (except cancel)
this.autoDispose(commands.createGroup(GridView.gridCommands, this, this.viewSection.hasFocus));
// Command groups implementing all grid level commands (except cancel)
this.autoDispose(commands.createGroup(viewCommands(GridView.gridCommands, this), this, this.viewSection.hasFocus));
this.autoDispose(commands.createGroup(GridView.gridFocusedCommands, this, this.viewSection.hasRegionFocus));

// Cancel command is registered conditionally, only when there is an active
// cell selection. This command is also used by Raw Data Views, to close the Grid popup.
const hasSelection = this.autoDispose(ko.pureComputed(() =>
Expand Down Expand Up @@ -295,6 +298,56 @@ GridView.selectionCommands = {
// TODO: move commands with modifications to gridEditCommands and use a single guard for
// readonly state.
GridView.gridCommands = {
fillSelectionDown: function() { tableUtil.fillSelectionDown(this.getSelection(), this.tableModel); },
selectAll: function() { this.selectAll(); },
insertFieldBefore: function(event) { this._insertField(event, this.cursor.fieldIndex()); },
insertFieldAfter: function(event) { this._insertField(event, this.cursor.fieldIndex() + 1); },
makeHeadersFromRow: function() { this.makeHeadersFromRow(this.getSelection()); },
renameField: function() { this.renameColumn(this.cursor.fieldIndex()); },
hideFields: function() { this.hideFields(this.getSelection()); },
deleteFields: function() { this._deleteFields(); },
clearColumns: function() { this._clearColumns(this.getSelection()); },
convertFormulasToData: function() { this._convertFormulasToData(this.getSelection()); },
sortAsc: function() {
sortBy(this.viewSection.activeSortSpec, this.currentColumn().getRowId(), Sort.ASC);
},
sortDesc: function() {
sortBy(this.viewSection.activeSortSpec, this.currentColumn().getRowId(), Sort.DESC);
},
addSortAsc: function() {
addToSort(this.viewSection.activeSortSpec, this.currentColumn().getRowId(), Sort.ASC);
},
addSortDesc: function() {
addToSort(this.viewSection.activeSortSpec, this.currentColumn().getRowId(), Sort.DESC);
},
toggleFreeze: function() {
// get column selection
const selection = this.getSelection();
// convert it menu option
const options = this._getColumnMenuOptions(selection);
// generate action that is available for freeze toggle
const action = freezeAction(options);
// if no action, do nothing
if (!action) { return; }
// if grist document is in readonly - simply change the value
// without saving
if (this.isReadonly) {
this.viewSection.rawNumFrozen(action.numFrozen);
return;
}
this.viewSection.rawNumFrozen.setAndSave(action.numFrozen);
},
copy: function() { console.log(this.getSelection()); return this.copy(this.getSelection()); },
cut: function() { return this.cut(this.getSelection()); },
paste: async function(pasteObj, cutCallback) {
if (this.gristDoc.isReadonly.get()) { return; }
await this.gristDoc.docData.bundleActions(null, () => this.paste(pasteObj, cutCallback));
await this.scrollToCursor(false);
},
};

// These commands are enabled only when the grid is the user-focused region.
GridView.gridFocusedCommands = {
cursorDown: function() {
if (this.cursor.rowIndex() === this.viewData.peekLength - 1) {
// When the cursor is in the bottom row, the view may not be scrolled all the way to
Expand Down Expand Up @@ -335,58 +388,10 @@ GridView.gridCommands = {
ctrlShiftUp: function () { this._shiftSelectUntilFirstOrLastNonEmptyCell({direction: 'up'}); },
ctrlShiftRight: function () { this._shiftSelectUntilFirstOrLastNonEmptyCell({direction: 'right'}); },
ctrlShiftLeft: function () { this._shiftSelectUntilFirstOrLastNonEmptyCell({direction: 'left'}); },
fillSelectionDown: function() { tableUtil.fillSelectionDown(this.getSelection(), this.tableModel); },

selectAll: function() { this.selectAll(); },

fieldEditSave: function() { this.cursor.rowIndex(this.cursor.rowIndex() + 1); },
// Re-define editField after fieldEditSave to make it take precedence for the Enter key.
editField: function(event) { closeRegisteredMenu(); this.scrollToCursor(true); this.activateEditorAtCursor({event}); },
insertFieldBefore: function(event) { this._insertField(event, this.cursor.fieldIndex()); },
insertFieldAfter: function(event) { this._insertField(event, this.cursor.fieldIndex() + 1); },
makeHeadersFromRow: function() { this.makeHeadersFromRow(this.getSelection()); },
renameField: function() { this.renameColumn(this.cursor.fieldIndex()); },
hideFields: function() { this.hideFields(this.getSelection()); },
deleteFields: function() { this._deleteFields(); },
clearValues: function() { this.clearValues(this.getSelection()); },
clearColumns: function() { this._clearColumns(this.getSelection()); },
convertFormulasToData: function() { this._convertFormulasToData(this.getSelection()); },
copy: function() { return this.copy(this.getSelection()); },
cut: function() { return this.cut(this.getSelection()); },
paste: async function(pasteObj, cutCallback) {
if (this.gristDoc.isReadonly.get()) { return; }
await this.gristDoc.docData.bundleActions(null, () => this.paste(pasteObj, cutCallback));
await this.scrollToCursor(false);
},
sortAsc: function() {
sortBy(this.viewSection.activeSortSpec, this.currentColumn().getRowId(), Sort.ASC);
},
sortDesc: function() {
sortBy(this.viewSection.activeSortSpec, this.currentColumn().getRowId(), Sort.DESC);
},
addSortAsc: function() {
addToSort(this.viewSection.activeSortSpec, this.currentColumn().getRowId(), Sort.ASC);
},
addSortDesc: function() {
addToSort(this.viewSection.activeSortSpec, this.currentColumn().getRowId(), Sort.DESC);
},
toggleFreeze: function() {
// get column selection
const selection = this.getSelection();
// convert it menu option
const options = this._getColumnMenuOptions(selection);
// generate action that is available for freeze toggle
const action = freezeAction(options);
// if no action, do nothing
if (!action) { return; }
// if grist document is in readonly - simply change the value
// without saving
if (this.isReadonly) {
this.viewSection.rawNumFrozen(action.numFrozen);
return;
}
this.viewSection.rawNumFrozen.setAndSave(action.numFrozen);
},
viewAsCard() {
const selectedRows = this.selectedRows();
if (selectedRows.length !== 1) { return; }
Expand Down
Loading
Loading