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

Conversation

manuhabitela
Copy link
Collaborator

@manuhabitela manuhabitela commented Feb 11, 2025

Context

See #1251. The gist of it is:

  • allow people to use the keyboard to navigate normally on pages without widgets
  • allow people to cycle through page main panels, in addition to page widgets, when on a grist document.

Proposed solution

This tries to handle both the above tasks.

First commit enables classic keyboard navigation when not on a grist doc page. This is done by disabling the Clipboard catch-all system when we conclude we don't need it ; and by forcing visible focus-rings to be applied if we detect keyboard usage. Just doing that is enough, as navigation-related commands that intercept Tab and others are not loaded on pages other than a grist doc.

Second commit is bigger and takes care of implementing a new behavior: pressing Ctrl+o and Ctrl+shift+o, now instead of cycling through widgets, cycles through widgets and page panels. Also, a new shortcut, Ctrl+alt+o, allows to focus to and from the creator panel.

With added logic on the view model allowing to keep track of whether it is the currently "focused panel" or not, we can easily toggle off each view commands when we focus the left or top panel, and toggle them back on when we focus back the main widgets area. This is the trick making the "Tab" key and others usable on panels when viewing a grist document.

This PR also makes sure the Ctrl+o shortcuts always work, even inside inputs, so that we don't open the browser file chooser by mistake when navigating. You could say needing to do that asks the question of why we use this keybinding in the first place (good question), but at least for now making sure it's totally overridden makes more sense in my pov.

Status

This is still WIP:

  • I'm not that proud of the code written, mostly because I created a tight link between PagePanels and ViewLayout, while they actually don't know about each other. And the focusSection command I added is basically used as a global function to do as I please. I am currently rewriting stuff so that it's cleaner. edit: code is now much cleaner, new code should mostly remain in the RegionFocusSwitcher class.
  • there are still a few bugs that I must handle
  • I'm sure I missed a few things
  • no tests are written

But I think this is overall a pretty good start (close to the finish line actually). I feel like the main idea of the code wouldn't change that much after some cleaning. Some more grist-trained eyes will be appreciated when I'm done for sure!

Impact

Merging this would drastically improve exhaustive keyboard navigation for sighted users, as it directly enables lots of interactive elements to now be focused and triggered through the keyboard.

After this step, some more work would be needed for specific elements and components where things like unfocusable divs are used. But this handles like 70% of the cases (random data out of my magic hat).

Related issues

Fixes #1251.

Has this been tested?

  • 👍 yes, I added tests to the test suite
  • 💭 no, because this PR is a draft and still needs work
  • 🙅 no, because this is not relevant here
  • 🙋 no, because I need help

Screenshots / Screencasts

grist-kb-nav.mp4

ping @dsagal if you are curious!

@manuhabitela manuhabitela self-assigned this Feb 11, 2025
@manuhabitela manuhabitela force-pushed the issue-1251-keyboard-nav branch from 9ce2d5c to 35b7a19 Compare February 11, 2025 13:29
@manuhabitela manuhabitela force-pushed the issue-1251-keyboard-nav branch 6 times, most recently from 2826389 to d531f28 Compare February 13, 2025 18:05
@manuhabitela
Copy link
Collaborator Author

Ok this is starting to get way bigger than I expected (again).

I think I'm getting close to something that works but this needs way more actual testing at this point (manual testing I mean for now).

@manuhabitela manuhabitela force-pushed the issue-1251-keyboard-nav branch 3 times, most recently from 9c140c4 to f9ec279 Compare February 14, 2025 16:16
@manuhabitela
Copy link
Collaborator Author

manuhabitela commented Feb 14, 2025

Ok, update!

In the end there is quite a bit of code. It's rather contained in its own class though.

The whole logic is still similar to what I wrote in the PR description, with new things mostly to handle mouse interactions and fine-tune the details. If you want details, you can check the second commit message, it explains everything. The code should also be rather easy to read, but I know it's starting to get quite busy…

The whole idea is:

  • When clicking inside the top/left/right panel, or using the Ctrl+O shortcut to focus a panel, the grist doc view commands are disabled. This is the first step to make keyboard fully usable on a grist doc: it clears the Tab key from being blocked by the Cursor "nextField" command.
  • DOM inside left/top/right panels is now excluded from the clipboard catch-all system. This is the second step: when we get to focus the panels, pressing Tab or clicking somewhere in the panel won't redirect the focus to the hidden textarea element.

Help appreciated about grainjs/knockout observables and grist doc loading

One thing I was stuck on is related to the grist document loading.

The RegionFocusSwitcher class knows whether or not it needs to handle the special case of a grist doc view layout, because we can optionally pass it the Observable<GristDoc | null> that is in the AppUi#pagePanelsDoc.

Thing is, at this point the grist doc/viewLayout is not done loading. So I have some weird _dirtyClassesFix method because I didn't get how to correctly wait for everything to be loaded. My goals are :

  1. to be sure that, at load, I know if the screen is currently rendering widgets, or not (meaning, I'm either not on a grist doc, or not on a grist doc "special page" like the code view),
  2. that the panelAttrs listen for this gristdoc state and apply classes accordingly.

I'm sure the way I load the grist doc in the class is not that great, but oh well, if anyone has 15 minutes to help on that it would be much appreciated.

Next steps for me

I'll now see where tests break to see if the logic is viable or if it's too… ambitious. Especially the mouse logic of totally disabling view commands on panel click, I fear that this breaks multiples things I don't know about.

I didn't test quite enough all interactions, especially when FocusLayer are added/removed. And I'm not sure the idea of disabling view commands when focusing panels is actually what we would want.

Any insight is appreciated.

@manuhabitela manuhabitela added the preview Launch preview deployment of this PR label Feb 14, 2025
Copy link
Contributor

Deployed commit f9ec279cd504cb2487ca5539c007e75cb51d63a6 as https://grist-manuhabitela-grist-core-issue-1251-keyboard-nav.fly.dev (until 2025-03-16T16:50:23.936Z)

Copy link
Contributor

Deployed commit dcb8a952e163e36209fa9b8645f86164c79830c7 as https://grist-manuhabitela-grist-core-issue-1251-keyboard-nav.fly.dev (until 2025-03-20T15:17:43.455Z)

@manuhabitela manuhabitela force-pushed the issue-1251-keyboard-nav branch from dcb8a95 to 4d69b18 Compare February 18, 2025 17:07
Copy link
Contributor

Deployed commit 4d69b1826e05ed52b41ba6272800d19eafab1f9b as https://grist-manuhabitela-grist-core-issue-1251-keyboard-nav.fly.dev (until 2025-03-20T17:13:18.224Z)

@manuhabitela manuhabitela force-pushed the issue-1251-keyboard-nav branch from 4d69b18 to d4441ec Compare March 3, 2025 12:05
Copy link
Contributor

github-actions bot commented Mar 3, 2025

Deployed commit d4441eced660329c1c7156b0d5a863340a400bd2 as https://grist-manuhabitela-grist-core-issue-1251-keyboard-nav.fly.dev (until 2025-04-02T12:10:14.867Z)

@manuhabitela manuhabitela force-pushed the issue-1251-keyboard-nav branch from d4441ec to 05ff0ea Compare March 3, 2025 13:35
Copy link
Contributor

github-actions bot commented Mar 3, 2025

Deployed commit 05ff0ea2889727bdebc476ffa60cbada61bd4e3e as https://grist-manuhabitela-grist-core-issue-1251-keyboard-nav.fly.dev (until 2025-04-02T13:41:11.180Z)

@manuhabitela manuhabitela force-pushed the issue-1251-keyboard-nav branch from 05ff0ea to adeefb8 Compare March 25, 2025 16:27
Copy link
Contributor

Deployed commit adeefb84bf0c4bf41e3d0938c58d85fe4baf8aca as https://grist-manuhabitela-grist-core-issue-1251-keyboard-nav.fly.dev (until 2025-04-24T16:32:49.676Z)

@manuhabitela manuhabitela force-pushed the issue-1251-keyboard-nav branch from adeefb8 to a355820 Compare March 25, 2025 17:16
Copy link
Contributor

Deployed commit a355820c310279502ca89e3e9072688508ff6da3 as https://grist-manuhabitela-grist-core-issue-1251-keyboard-nav.fly.dev (until 2025-04-24T17:22:11.874Z)

@manuhabitela manuhabitela force-pushed the issue-1251-keyboard-nav branch from a355820 to c481f42 Compare March 25, 2025 18:05
Copy link
Contributor

Deployed commit c481f4216724c0dbf73aafa956bb079cafee7b71 as https://grist-manuhabitela-grist-core-issue-1251-keyboard-nav.fly.dev (until 2025-04-24T18:11:04.009Z)

@manuhabitela manuhabitela force-pushed the issue-1251-keyboard-nav branch from c481f42 to 8993a4e Compare March 25, 2025 18:36
@manuhabitela manuhabitela force-pushed the issue-1251-keyboard-nav branch from 5bc5ad2 to fce83fe Compare April 18, 2025 21:48
Copy link
Contributor

Deployed commit fce83fedc839ba54c1d14a2c3a8108f539a28ed1 as https://grist-manuhabitela-grist-core-issue-1251-keyboard-nav.fly.dev (until 2025-05-18T21:53:52.814Z)

@manuhabitela
Copy link
Collaborator Author

manuhabitela commented Apr 18, 2025

Okay, update:

  • all existing tests pass (a first in this PR 🙃). There were slight changes in the behavior of 2 tests, where pressing escape between actions is now required, we should make sure this change is acceptable
  • I didn't add any test for new code yet
  • for now the behavior is not ideal on cases where popups appear from panels. For example, transforming a column type makes a popup appear. As soon as we close the popup to apply the transform, the focus goes back on the grid. This behavior is the intended one/normal one when using the mouse. But I'd like to detect if the user uses the keyboard and rather focus back the column type select when the popup closes. This is the focusLayer focusing back on default element when a dom element is removed. Not sure I'll handle that in this PR though… while the behavior in those cases is not great, it's still a big improvement compared to before.
  • the last commit introduces some temporary code that I added only to verify if what I wanted to do worked, it needs some refactoring. The way the things are initialized is a bit weird, I'm working on making that better.

So, what I need to do:

  • add e2e tests (edit: a first batch of tests were added, a few more complicated ones need to be added still)
  • clean the way how things are initialized (edit: "clean" is not the word i'd use in the end lol, it's a bit of a mess still, but it's more contained and I think things are placed where they should be now…)
  • Florent suggested reworking the whole "Clipboard" stuff, where the naming is confusing (it handles the copy/paste stuff sure, but its main usage accross the app is related tp keyboard focus handling). Not sure I'll spend additional time on doing clever stuff, but maybe just renaming "Clipboard" to something else would be an improvement.

@manuhabitela manuhabitela force-pushed the issue-1251-keyboard-nav branch from fce83fe to e760c4c Compare April 24, 2025 13:58
1) Disable the clipboard catch-all element when there is no need to
enable it (we conclude we don't need it when we see no copy/put/paste
command is set).

This means it is disabled on most pages except the document view.

2) Make sure interactive elements are highlighted when focusing them
with the keyboard. Lots of elements have `outline: none` forced in the
codebase to prevent showing unwanted outlines when clicking with the
mouse. But it prevents keyboard users to see where they are. Have a tiny
JS script checking for keyboard interactions to force showing the
outline on actual kb focus.
RegionFocusSwitcher makes it possible for the user to cycle through app
panels and document widgets (named "regions" as a whole) with a couple
keyboard shortcuts.

1) On a grist document page, pressing the Ctrl+o (or Ctrl+Shift+o)
shortcut jumps your keyboard focus from widget to widget, but also panel
to panel. You go to widget1, 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 and you can't go out ot them.

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 with Ctrl+Alt+o.
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 in a notification.

3) Pressing Esc while on a panel will first focus back the panel, then
restore focus to the view layout.

4) The grist doc view now keeps track of this new stuff, in order to
disable the view section commands when focusing panels. This is an
important trick that lets us enable the Tab key and others when focusing
the panels.

5) 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.

6) One 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 inside the one having the class.

6) On a grist doc, mouse interactions also drastically impact keyboard
navigation: they now silently toggle the view layout commands. If I
click on the left panel, it wont actually force a focus change, but it
will enable keyboard nav inside the panel.

This is not done yet:
- needs more actual usage testing to finetune the behavior and make sure
it is what we want.
- needs tests
we now check if the passed doc observer is actually ready before doing
anything
i actually don't understand why I didn't stumble on this sooner but oh
well…
- we basically dont want to focus back the current panel with esc, if we
didn't go on it via the cycle commands before. just directly reset when
we dont come from cycle commands.
before, we listened for clicks anywhere in the document. It seems
that some e2e tests do some trickery by specifically clicking on
out-of-the-app-ui stuff… Do not interfer when doing that.

This is more of a precaution in order to make sure we only do things on
purpose.
before, panel content was always focusable. This made some unintended
behavior, like clicking a "a" element would keep the focus, and make the
`input` command stop working.

now if we just click a "a" element inside a non-focused panel, it wont
grab focus.
sometimes, we want to run "current view"-related commands via keyboard
shortcuts, while we are focusing ie. the creator panel.

this commit makes it possible, by distinguishing "globally active"
view commands from "only user-focused" view commands.

in order to have the "globally active" commands more reliably work, we
make sure the active section is focused by the region switcher
automatically. it's not an ideal behavior for the user, as he will then
need to manually go focus back the panel he was on ; but this is more
done as a safety measure to not end up in a state where `body` is
focused while we'd expect otherwise.
since most kb-related things in grist views are handled manually with
specific js, we dont want a default focus ring on stuff that might get
focus inside them.

before that, a focus ring was added on celleditor_text_editor for
example.
the idea is to fix all the interactions where we start focused on the
creator panel and the app ends up focusing the clipboard element by
itself, for example when transforming a column type (after applying the
transform).

In those cases for now we end up in a weird state where the
regionFocusSwitcher says we are on the creator panel, but the
document.activeElement is the hidden textarea (clipboard el).
- two interactions now need the user to first press esc to get out of
current focus before continuing (I'd say an acceptable change?)
- sometimes when we want to return on the "app focus", it won't mean the
clipboard element anymore
waits were already done here and there in the code but not everywhere,
making tests breaking randomly when opening dropdowns
some tests break because we want to click too early on elements not
there yet, because they appear in dropdown menus right after clicking on
buttons showing them. just wait a bit
- now the RFS is created in the App, I guess it makes more sense here,
because it's also where the Clipboard is, and now we can more easilly
access it across the app if needed…
- …but I have a whole mess when initializing now (more than before), to
make sure panelAttrs are applied correctly after the grist doc loaded…
i'm sure this can be improved somehow?
- no more dirty window.regionFocusSwitcher
not sure this is a good way to handle that, but it was a bit convoluted
to keep track of state change initiator in "_currentUpdateInitiator".
Now that it's in the state object it's more logical I think.

But i'm not sure of the implications of putting a mouse event object in
an observable?
more advanced tests are missing, but this is a good start
@manuhabitela manuhabitela force-pushed the issue-1251-keyboard-nav branch from e760c4c to dd5b990 Compare April 24, 2025 14:00
Copy link
Contributor

Deployed commit dd5b990bf29c3b5160bcc266c8c35d559c75cce1 as https://grist-manuhabitela-grist-core-issue-1251-keyboard-nav.fly.dev (until 2025-05-24T14:05:55.612Z)

Just trying to disable the mouse-specific stuff to see if tests pass or
not right now.
@manuhabitela manuhabitela force-pushed the issue-1251-keyboard-nav branch from 8f33c7e to fa075c2 Compare May 13, 2025 15:08
Copy link
Contributor

Deployed commit fa075c2ea18fb3446567f89624b01ec205ceda37 as https://grist-manuhabitela-grist-core-issue-1251-keyboard-nav.fly.dev (until 2025-06-12T15:14:05.451Z)

Comment on lines +315 to +321
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;
}
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.

region: undefined,
initiator: undefined,
});
this._initiated = Observable.create(this, false);
Copy link
Collaborator Author

@manuhabitela manuhabitela May 13, 2025

Choose a reason for hiding this comment

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

The whole way I initialize the RegionFocusSwitcher (RFS) is a bit… convoluted, to say the least. The RFS is initialized in the App.

More info about the requirements of it all:

  • the RFS applies classes on page panel dom elements via the panelAttrs method, and those classes depend on whether or not a grist doc is loaded. I get the grist doc via the App page model
  • the RFS listens for clicks made on the page container
  • we listen for clipboard-related events that are fired on the App

I didn't find a clean way to be sure to start everything only once the grist doc has finished initializing the first time, while still being able to access App related stuff.

Note the panel classes need to listen for the grist doc changes, because the classes change depending on whether the grist doc is currently actually shown or the current page is a "special page" like the code view.

This is why I end up with multiple listeners that are just there to start things when stuff has finished loading. And the _initiated internal variable is a listener because I also listen to it in the panelAttrs method.

And to top it of, when the app.pageModel is reset, I manually also reset stuff inside the RFS. This whole thing is not enough though as I still have failing tests when the pageModel gets reset somewhere else and I don't act accordingly. It's just yet another thing showing this is not the correct way to initialize all this.


@dsagal this is my main pain point on this PR, if you or maybe @georgegevoian, @berhalak have any insight on how to initialize this correctly, I'm all ears 🙏

The gist of it is: the RegionFocusSwitcher needs to know if there is a current grist document, and if the view sections are currently shown or not.

Comment on lines 141 to 196
dom.cls('kb-focus-highlighter-group', use => {
use(this._initiated);
// highlight focused elements everywhere except in the grist doc views
if (id !== 'main') {
return true;
}
const gristDoc = this._gristDocObs ? use(this._gristDocObs) : null;
if (!gristDoc) {
return true;
}
if (gristDoc) {
use(gristDoc.activeViewId);
}
return isSpecialPage(gristDoc);
}),
dom.cls('clipboard_group_focus', use => {
use(this._initiated);
const gristDoc = this._gristDocObs ? use(this._gristDocObs) : null;
// if we are not on a grist doc, whole page is always focusable
if (!gristDoc) {
return true;
}
const current = use(this._state).region;
// on a grist doc, panel content is focusable only if it's the current region
if (current?.type === 'panel' && current.id === id) {
return true;
}
if (gristDoc) {
use(gristDoc.activeViewId);
}
// on a grist doc, main panel is focusable only if we are not the actual document view
return isSpecialPage(gristDoc);
}),
dom.cls('clipboard_forbid_focus', use => {
use(this._initiated);
// forbid focus only on the main panel when on an actual document view (and not a special page)
if (id !== 'main') {
return false;
}
const gristDoc = this._gristDocObs ? use(this._gristDocObs) : null;
if (!gristDoc) {
return false;
}
if (gristDoc) {
use(gristDoc.activeViewId);
}
if (isSpecialPage(gristDoc)) {
return false;
}
return true;
}),
dom.cls(`${cssFocusedPanel.className}-focused`, use => {
use(this._initiated);
const current = use(this._state);
return current.initiator?.type === 'cycle' && current.region?.type === 'panel' && current.region.id === id;
}),
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

in this, I use the cls helper multiple times to apply different classes. But the content of each callback is rather similar, they mostly subscribe to the same observables.

Is there a way for me to write only one callback to apply different classes? I feel this could end up running way less code each time an observable changes.

Comment on lines +86 to +94
return dom.create(WelcomePage, appModel, appObj);
} else if (pageType === 'account') {
return domAsync(loadAccountPage().then(ap => dom.create(ap.AccountPage, appModel)));
return domAsync(loadAccountPage().then(ap => dom.create(ap.AccountPage, appModel, appObj)));
} else if (pageType === 'admin') {
return domAsync(loadAdminPanel().then(m => dom.create(m.AdminPanel, appModel)));
return domAsync(loadAdminPanel().then(m => dom.create(m.AdminPanel, appModel, appObj)));
} else if (pageType === 'activation') {
return domAsync(loadActivationPage().then(ap => dom.create(ap.getActivationPage(), appModel)));
} else if (pageType === 'audit-logs') {
return domAsync(loadAuditLogsPage().then(m => dom.create(m.AuditLogsPage, appModel)));
return domAsync(loadAuditLogsPage().then(m => dom.create(m.AuditLogsPage, appModel, appObj)));
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I didn't update all the pages because it seems some are related to the EE edition but if this gets merged as is, they would need to be updated to benefit from the region focus switcher.

this is yet another thing showing I don't initialize stuff where I
should… help appreciated
Copy link
Contributor

Deployed commit 27d5180a8688661a45a453b01767d42f7177394d as https://grist-manuhabitela-grist-core-issue-1251-keyboard-nav.fly.dev (until 2025-06-13T08:57:23.731Z)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
gouv.fr preview Launch preview deployment of this PR
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Make keyboard usable outside of tables
1 participant