3
3
*/
4
4
import { makeT } from 'app/client/lib/localization' ;
5
5
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' ;
7
7
import { urlState } from "app/client/models/gristUrlState" ;
8
8
import { resizeFlexVHandle } from 'app/client/ui/resizeHandle' ;
9
9
import { hoverTooltip } from 'app/client/ui/tooltips' ;
10
10
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' ;
12
14
import { isNarrowScreenObs } from 'app/client/ui2018/cssVars' ;
13
15
import { icon } from 'app/client/ui2018/icons' ;
14
16
import {
@@ -50,7 +52,21 @@ export interface PageContents {
50
52
contentBottom ?: DomElementArg ;
51
53
}
52
54
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
+ ) {
54
70
const testId = page . testId || noTestId ;
55
71
const left = page . leftPanel ;
56
72
const right = page . rightPanel ;
@@ -94,7 +110,98 @@ export function pagePanels(page: PageContents) {
94
110
( left . panelOpen as SessionObs < boolean > ) ?. pauseSaving ?.( yesNo ) ;
95
111
} ;
96
112
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
+
97
201
const commandsGroup = commands . createGroup ( {
202
+ nextPanel : ( ) => goToPanel ( 'next' ) ,
203
+ prevPanel : ( ) => goToPanel ( 'prev' ) ,
204
+ creatorPanel : toggleCreatorPanelFocus ,
98
205
leftPanelOpen : ( ) => new Promise ( ( resolve ) => {
99
206
const watcher = new TransitionWatcher ( leftPaneDom ) ;
100
207
watcher . onDispose ( ( ) => resolve ( undefined ) ) ;
@@ -136,6 +243,12 @@ export function pagePanels(page: PageContents) {
136
243
cssContentMain (
137
244
leftPaneDom = cssLeftPane (
138
245
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' ) ,
139
252
cssOverflowContainer (
140
253
contentWrapper = cssLeftPanelContainer (
141
254
cssLeftPaneHeader (
@@ -272,6 +385,12 @@ export function pagePanels(page: PageContents) {
272
385
cssMainPane (
273
386
mainHeaderDom = cssTopHeader (
274
387
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' ) ,
275
394
( left . hideOpener ? null :
276
395
cssPanelOpener ( 'PanelRight' , cssPanelOpener . cls ( '-open' , left . panelOpen ) ,
277
396
testId ( 'left-opener' ) ,
@@ -292,7 +411,16 @@ export function pagePanels(page: PageContents) {
292
411
) ,
293
412
dom . style ( 'margin-bottom' , use => use ( bannerHeight ) + 'px' ) ,
294
413
) ,
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
+
296
424
cssMainPane . cls ( '-left-overlap' , leftOverlap ) ,
297
425
testId ( 'main-pane' ) ,
298
426
) ,
@@ -306,6 +434,12 @@ export function pagePanels(page: PageContents) {
306
434
307
435
rightPaneDom = cssRightPane (
308
436
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' ) ,
309
443
cssRightPaneHeader (
310
444
right . header ,
311
445
dom . style ( 'margin-bottom' , use => use ( bannerHeight ) + 'px' )
@@ -402,6 +536,18 @@ const cssContentMain = styled(cssHBox, `
402
536
flex: 1 1 0px;
403
537
overflow: hidden;
404
538
` ) ;
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
+
405
551
export const cssLeftPane = styled ( cssVBox , `
406
552
position: relative;
407
553
background-color: ${ theme . leftPanelBg } ;
0 commit comments