Skip to content

Commit 9ce2d5c

Browse files
committed
(kb) introduce panels keyboard navigation with new commands
1) Add nextPanel and prevPanel commands that are bound on the same key as nextSection and prevSection commands. On grist document pages: new behavior cycles through page panels in addition to page widgets when pressing the keybinding. Pressing Ctrl+o will first trigger the nextSection command as usual. *But*, when we are on the last widget and press Ctrl+o, instead of cycling back to the first one, it will go focus the left sidebar. Then the top bar. Then go back to the first widget. Etc. On other pages: pressing Ctrl+o now cycles through the page panels to allow quick keyboard navigation between the app parts. It cycles through left panel, top panel and main content area. 2) Add creatorPanel command to keyboard focus from/to the creator panel. This is a separate command from the panels-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. 3) ViewModel now keeps track of whether the view "panel" is the currently focused one or not, and this is now a condition to consider a viewSection "hasFocus" or not. This allows to easily disable viewSection commands when focusing app panels. This is not done yet: - needs more actual usage testing to finetune the behavior and make sure it is what we want. The ViewModel change might be too much - I need to rework the creatorPanel stuff as it doesn't take into account the ViewModel focus - 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 9a20347 commit 9ce2d5c

File tree

8 files changed

+226
-13
lines changed

8 files changed

+226
-13
lines changed

app/client/components/Clipboard.js

+9-4
Original file line numberDiff line numberDiff line change
@@ -305,12 +305,17 @@ async function getTextFromClipboardItem(clipboardItem, type) {
305305
/**
306306
* Helper to determine if the currently active element deserves to keep its own focus, and capture
307307
* 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.
308+
* copy-paste target by adding 'clipboard_focus' class to it. You can explicitly disallow focus
309+
* on an element by adding the 'clipboard_ignore' class to it.
309310
*/
310311
function allowFocus(elem) {
311-
return elem && (FOCUS_TARGET_TAGS.hasOwnProperty(elem.tagName) ||
312-
elem.hasAttribute("tabindex") ||
313-
elem.classList.contains('clipboard_focus'));
312+
return elem
313+
&& !elem.classList.contains('clipboard_ignore')
314+
&& (
315+
FOCUS_TARGET_TAGS.hasOwnProperty(elem.tagName) ||
316+
elem.hasAttribute("tabindex") ||
317+
elem.classList.contains('clipboard_focus')
318+
);
314319
}
315320

316321
Clipboard.allowFocus = allowFocus;

app/client/components/ViewLayout.ts

+23-2
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,17 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
197197
deleteSection: () => { this.removeViewSection(this.viewModel.activeSectionId()).catch(reportError); },
198198
nextSection: () => { this._otherSection(+1); },
199199
prevSection: () => { this._otherSection(-1); },
200+
// todo: transform firstSection/lastSection to setSection so that creatorPanel can use it to toggle on/off and profit from isFocusedPanel ?
201+
firstSection: () => {
202+
this.viewModel.isFocusedPanel(true);
203+
const sectionIds = this.layout.getAllLeafIds();
204+
this.viewModel.activeSectionId(sectionIds[0]);
205+
},
206+
lastSection: () => {
207+
this.viewModel.isFocusedPanel(true);
208+
const sectionIds = this.layout.getAllLeafIds();
209+
this.viewModel.activeSectionId(sectionIds[sectionIds.length - 1]);
210+
},
200211
printSection: () => { printViewSection(this.layout, this.viewModel.activeSection()).catch(reportError); },
201212
sortFilterMenuOpen: (sectionId?: number) => { this._openSortFilterMenu(sectionId); },
202213
expandSection: () => { this._expandSection(); },
@@ -401,13 +412,23 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
401412

402413
// Select another section in cyclic ordering of sections. Order is counter-clockwise if given a
403414
// positive `delta`, clockwise otherwise.
415+
// If requesting an out of bounds section, we redirect to focusing another page panel.
404416
private _otherSection(delta: number) {
405417
const sectionIds = this.layout.getAllLeafIds();
406418
const sectionId = this.viewModel.activeSectionId.peek();
407419
const currentIndex = sectionIds.indexOf(sectionId);
408420
const index = mod(currentIndex + delta, sectionIds.length);
409-
// update the active section id
410-
this.viewModel.activeSectionId(sectionIds[index]);
421+
422+
if (currentIndex + delta === -1) {
423+
this.viewModel.isFocusedPanel(false);
424+
commands.allCommands.prevPanel.run();
425+
} else if (currentIndex + delta === sectionIds.length) {
426+
this.viewModel.isFocusedPanel(false);
427+
commands.allCommands.nextPanel.run();
428+
} else {
429+
this.viewModel.isFocusedPanel(true);
430+
this.viewModel.activeSectionId(sectionIds[index]);
431+
}
411432
}
412433

413434
private _maybeFocusInSection() {

app/client/components/commandList.ts

+31-2
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ export type CommandName =
4545
| 'prevPage'
4646
| 'nextSection'
4747
| 'prevSection'
48+
| 'firstSection'
49+
| 'lastSection'
50+
| 'nextPanel'
51+
| 'prevPanel'
52+
| 'creatorPanel'
4853
| 'shiftDown'
4954
| 'shiftUp'
5055
| 'shiftRight'
@@ -375,11 +380,35 @@ export const groups: CommendGroupDef[] = [{
375380
}, {
376381
name: 'nextSection',
377382
keys: ['Mod+o'],
378-
desc: 'Activate next page widget',
383+
desc: 'Focus next page widget or panel',
379384
}, {
380385
name: 'prevSection',
381386
keys: ['Mod+Shift+O'],
382-
desc: 'Activate previous page widget',
387+
desc: 'Focus previous page widget or panel',
388+
}, {
389+
// This is superseeded by nextSection on pages with view layouts,
390+
// but keys mapping is important for pages without view layouts.
391+
name: 'nextPanel',
392+
keys: ['Mod+o'],
393+
desc: '',
394+
}, {
395+
// This is superseeded by prevSection on pages with view layouts,
396+
// but keys mapping is important for pages without view layouts.
397+
name: 'prevPanel',
398+
keys: ['Mod+Shift+O'],
399+
desc: '',
400+
}, {
401+
name: 'firstSection',
402+
keys: [],
403+
desc: '',
404+
}, {
405+
name: 'lastSection',
406+
keys: [],
407+
desc: '',
408+
}, {
409+
name: 'creatorPanel',
410+
keys: ['Mod+Alt+o'],
411+
desc: 'Toggle creator panel keyboard focus',
383412
}
384413
],
385414
}, {

app/client/models/entities/ViewRec.ts

+6
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ export interface ViewRec extends IRowModel<"_grist_Views"> {
1919
// This is active collapsed section id. Set when the widget is clicked.
2020
activeCollapsedSectionId: ko.Observable<number>;
2121

22+
// Whether the view content area is the currently keyboard-focused "panel".
23+
// This changes when we navigate through panels with nextPanel/prevPanel commands.
24+
// It is used to toggle the sections keyboard commands accordingly.
25+
isFocusedPanel: ko.Observable<boolean>;
26+
2227
// Saved collapsed sections.
2328
collapsedSections: ko.Computed<number[]>;
2429

@@ -41,6 +46,7 @@ export function createViewRec(this: ViewRec, docModel: DocModel): void {
4146
this.layoutSpecObj = modelUtil.jsonObservable(this.layoutSpec);
4247

4348
this.activeCollapsedSectionId = ko.observable(0);
49+
this.isFocusedPanel = ko.observable(true);
4450

4551
this.collapsedSections = this.autoDispose(ko.pureComputed(() => {
4652
const allSections = new Set(this.viewSections().all().map(x => x.id()));

app/client/models/entities/ViewSectionRec.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -703,7 +703,10 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
703703

704704
this.hasFocus = ko.pureComputed({
705705
// Read may occur for recently disposed sections, must check condition first.
706-
read: () => !this.isDisposed() && this.view().activeSectionId() === this.id(),
706+
read: () =>
707+
!this.isDisposed()
708+
&& this.view().activeSectionId() === this.id()
709+
&& this.view().isFocusedPanel(),
707710
write: (val) => { this.view().activeSectionId(val ? this.id() : 0); }
708711
});
709712

app/client/ui/AppUI.ts

+2
Original file line numberDiff line numberDiff line change
@@ -184,5 +184,7 @@ function pagePanelsDoc(owner: IDisposableOwner, appModel: AppModel, appObj: App)
184184
contentTop: buildDocumentBanners(pageModel),
185185
contentBottom: dom.create(createBottomBarDoc, pageModel, leftPanelOpen, rightPanelOpen),
186186
banner: dom.create(ViewAsBanner, pageModel),
187+
}, {
188+
cycleThroughMain: false,
187189
});
188190
}

app/client/ui/PagePanels.ts

+150-4
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
*/
44
import {makeT} from 'app/client/lib/localization';
55
import * as commands from 'app/client/components/commands';
6-
import {watchElementForBlur} from 'app/client/lib/FocusLayer';
6+
import {FocusLayer, watchElementForBlur} from 'app/client/lib/FocusLayer';
77
import {urlState} from "app/client/models/gristUrlState";
88
import {resizeFlexVHandle} from 'app/client/ui/resizeHandle';
99
import {hoverTooltip} from 'app/client/ui/tooltips';
1010
import {transition, TransitionWatcher} from 'app/client/ui/transitions';
11-
import {cssHideForNarrowScreen, isScreenResizing, mediaNotSmall, mediaSmall, theme} from 'app/client/ui2018/cssVars';
11+
import {
12+
cssHideForNarrowScreen, isScreenResizing, mediaNotSmall, mediaSmall, theme, vars
13+
} from 'app/client/ui2018/cssVars';
1214
import {isNarrowScreenObs} from 'app/client/ui2018/cssVars';
1315
import {icon} from 'app/client/ui2018/icons';
1416
import {
@@ -50,7 +52,21 @@ export interface PageContents {
5052
contentBottom?: DomElementArg;
5153
}
5254

53-
export function pagePanels(page: PageContents) {
55+
interface PagePanelsOptions {
56+
/**
57+
* If true, the main pane is included in the focus-cycle of panels through the nextPanel/prevPanel commands.
58+
*
59+
* Default is true. Useful to disable when the main pane handles its own focus internally, like a grist doc.
60+
*/
61+
cycleThroughMain?: boolean;
62+
}
63+
type FocusablePanelId = 'page-panel--left' | 'page-panel--top' | 'page-panel--right' | 'page-panel--main' | null;
64+
type CyclePanelId = Exclude<FocusablePanelId, 'page-panel--right'>;
65+
66+
export function pagePanels(
67+
page: PageContents,
68+
options: PagePanelsOptions = { cycleThroughMain: true }
69+
) {
5470
const testId = page.testId || noTestId;
5571
const left = page.leftPanel;
5672
const right = page.rightPanel;
@@ -94,7 +110,98 @@ export function pagePanels(page: PageContents) {
94110
(left.panelOpen as SessionObs<boolean>)?.pauseSaving?.(yesNo);
95111
};
96112

113+
const cycleThroughMain = options.cycleThroughMain;
114+
const cyclePanelIds: CyclePanelId[] = cycleThroughMain
115+
? ['page-panel--left', 'page-panel--top', 'page-panel--main']
116+
: ['page-panel--left', 'page-panel--top'];
117+
const focusedPanel: {
118+
id: Observable<FocusablePanelId>;
119+
focusedDomElement: Element | null;
120+
} = {
121+
id: Observable.create<FocusablePanelId>(null, null),
122+
focusedDomElement: null,
123+
};
124+
125+
let focusLayer: FocusLayer | null = null;
126+
const prevFocusedElements: Record<Exclude<FocusablePanelId, null>,
127+
Element | null
128+
> = {
129+
'page-panel--left': null,
130+
'page-panel--top': null,
131+
'page-panel--right': null,
132+
'page-panel--main': null,
133+
};
134+
135+
focusedPanel.id.addListener((current, prev) => {
136+
// Clean previously set focus layer any time the panel changes.
137+
if (focusLayer && !focusLayer.isDisposed()) {
138+
focusLayer.dispose();
139+
}
140+
141+
// Save previous panel/main pane previously focused element for later.
142+
// If 'prev' is null, it means we're switching from the main pane when cycleThroughMain is false.
143+
prevFocusedElements[prev || 'page-panel--main'] = document.activeElement;
144+
145+
// Create a new focus layer if we're switching to a panel.
146+
// Note that when cycleThroughMain is false, 'current' is null when it's the main pane,
147+
// so we don't handle focus in that case
148+
if (current) {
149+
focusLayer = FocusLayer.create(null, {
150+
defaultFocusElem: document.getElementById(current) as HTMLDivElement,
151+
allowFocus: () => true,
152+
});
153+
}
154+
155+
// setTimeout trick is there to prevent a race condition with the FocusLayer change and make sure this is done last.
156+
setTimeout(() => {
157+
const elementToFocusBack = current ? prevFocusedElements[current] : prevFocusedElements['page-panel--main'];
158+
if (elementToFocusBack instanceof HTMLElement) {
159+
elementToFocusBack.focus();
160+
}
161+
}, 0);
162+
});
163+
164+
const goToPanel = (direction: 'next' | 'prev') => {
165+
const focusedPanelId = focusedPanel.id.get();
166+
167+
if (focusedPanelId === 'page-panel--right') {
168+
return toggleCreatorPanelFocus();
169+
}
170+
171+
let newIndex = null;
172+
if (focusedPanelId === null) {
173+
newIndex = direction === 'next' ? 0 : cyclePanelIds.length - 1;
174+
} else {
175+
const index = cyclePanelIds.indexOf(focusedPanelId);
176+
newIndex = index + (direction === 'next' ? 1 : -1);
177+
}
178+
if (newIndex === (direction === 'next' ? cyclePanelIds.length : -1)) {
179+
focusedPanel.id.set(null);
180+
commands.allCommands[direction === 'next' ? 'firstSection' : 'lastSection'].run();
181+
} else {
182+
focusedPanel.id.set(cyclePanelIds[newIndex]);
183+
}
184+
};
185+
186+
187+
// todo: make viewModel.isFocusedPanel() understand the switch
188+
let prev: FocusablePanelId = null;
189+
const toggleCreatorPanelFocus = () => {
190+
if (!right?.panelOpen.get()) {
191+
right?.panelOpen.set(true);
192+
}
193+
if (focusedPanel.id.get() !== 'page-panel--right') {
194+
prev = focusedPanel.id.get();
195+
focusedPanel.id.set('page-panel--right');
196+
} else {
197+
focusedPanel.id.set(prev);
198+
}
199+
};
200+
97201
const commandsGroup = commands.createGroup({
202+
nextPanel: () => goToPanel('next'),
203+
prevPanel: () => goToPanel('prev'),
204+
creatorPanel: toggleCreatorPanelFocus,
98205
leftPanelOpen: () => new Promise((resolve) => {
99206
const watcher = new TransitionWatcher(leftPaneDom);
100207
watcher.onDispose(() => resolve(undefined));
@@ -136,6 +243,12 @@ export function pagePanels(page: PageContents) {
136243
cssContentMain(
137244
leftPaneDom = cssLeftPane(
138245
testId('left-panel'),
246+
dom.attr('id', 'page-panel--left'),
247+
dom.attr('tabindex', '-1'),
248+
dom.cls('clipboard_ignore'),
249+
dom.attr('role', 'region'),
250+
dom.attr('aria-label', t('Main navigation and document settings (left panel)')),
251+
cssFocusedPanel.cls('', use => use(focusedPanel.id) === 'page-panel--left'),
139252
cssOverflowContainer(
140253
contentWrapper = cssLeftPanelContainer(
141254
cssLeftPaneHeader(
@@ -272,6 +385,12 @@ export function pagePanels(page: PageContents) {
272385
cssMainPane(
273386
mainHeaderDom = cssTopHeader(
274387
testId('top-header'),
388+
dom.attr('id', 'page-panel--top'),
389+
dom.attr('tabindex', '-1'),
390+
dom.cls('clipboard_ignore'),
391+
dom.attr('role', 'region'),
392+
dom.attr('aria-label', t('Document header')),
393+
cssFocusedPanel.cls('', use => use(focusedPanel.id) === 'page-panel--top'),
275394
(left.hideOpener ? null :
276395
cssPanelOpener('PanelRight', cssPanelOpener.cls('-open', left.panelOpen),
277396
testId('left-opener'),
@@ -292,7 +411,16 @@ export function pagePanels(page: PageContents) {
292411
),
293412
dom.style('margin-bottom', use => use(bannerHeight) + 'px'),
294413
),
295-
page.contentMain,
414+
415+
cssContentMainPane(
416+
dom.attr('id', 'page-panel--main'),
417+
dom.attr('tabindex', '-1'),
418+
dom.cls('clipboard_ignore'),
419+
dom.attr('role', 'region'),
420+
dom.attr('aria-label', 'Main content'),
421+
page.contentMain,
422+
),
423+
296424
cssMainPane.cls('-left-overlap', leftOverlap),
297425
testId('main-pane'),
298426
),
@@ -306,6 +434,12 @@ export function pagePanels(page: PageContents) {
306434

307435
rightPaneDom = cssRightPane(
308436
testId('right-panel'),
437+
dom.attr('id', 'page-panel--right'),
438+
dom.attr('tabindex', '-1'),
439+
dom.cls('clipboard_ignore'),
440+
dom.attr('role', 'region'),
441+
dom.attr('aria-label', t('Creator panel (right panel)')),
442+
cssFocusedPanel.cls('', use => use(focusedPanel.id) === 'page-panel--right'),
309443
cssRightPaneHeader(
310444
right.header,
311445
dom.style('margin-bottom', use => use(bannerHeight) + 'px')
@@ -402,6 +536,18 @@ const cssContentMain = styled(cssHBox, `
402536
flex: 1 1 0px;
403537
overflow: hidden;
404538
`);
539+
540+
// div wrapping the contentMain passed to pagePanels
541+
const cssContentMainPane = styled(cssVBox, `
542+
flex-grow: 1;
543+
`);
544+
545+
const cssFocusedPanel = styled('div', `
546+
outline: 3px solid ${theme.widgetActiveBorder};
547+
z-index: ${vars.focusedPanelZIndex} !important;
548+
outline-offset: -3px;
549+
`);
550+
405551
export const cssLeftPane = styled(cssVBox, `
406552
position: relative;
407553
background-color: ${theme.leftPanelBg};

app/client/ui2018/cssVars.ts

+1
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ export const vars = {
137137
insertColumnLineZIndex: new CustomProp('insert-column-line-z-index', '20'),
138138
emojiPickerZIndex: new CustomProp('modal-z-index', '20'),
139139
popupSectionBackdropZIndex: new CustomProp('popup-section-backdrop-z-index', '100'),
140+
focusedPanelZIndex: new CustomProp('focused-panel-z-index', '900'),
140141
menuZIndex: new CustomProp('menu-z-index', '999'),
141142
modalZIndex: new CustomProp('modal-z-index', '999'),
142143
onboardingBackdropZIndex: new CustomProp('onboarding-backdrop-z-index', '999'),

0 commit comments

Comments
 (0)