@@ -5,13 +5,14 @@ import { getIonMode } from '../../global/ionic-global';
5
5
import { Animation , Gesture , GestureDetail , MenuChangeEventDetail , MenuI , Side } from '../../interface' ;
6
6
import { getTimeGivenProgression } from '../../utils/animation/cubic-bezier' ;
7
7
import { GESTURE_CONTROLLER } from '../../utils/gesture' ;
8
- import { assert , clamp , isEndSide as isEnd } from '../../utils/helpers' ;
8
+ import { assert , clamp , inheritAttributes , isEndSide as isEnd } from '../../utils/helpers' ;
9
9
import { menuController } from '../../utils/menu-controller' ;
10
10
11
11
const iosEasing = 'cubic-bezier(0.32,0.72,0,1)' ;
12
12
const mdEasing = 'cubic-bezier(0.0,0.0,0.2,1)' ;
13
13
const iosEasingReverse = 'cubic-bezier(1, 0, 0.68, 0.28)' ;
14
14
const mdEasingReverse = 'cubic-bezier(0.4, 0, 0.6, 1)' ;
15
+ const focusableQueryString = '[tabindex]:not([tabindex^="-"]), input:not([type=hidden]):not([tabindex^="-"]), textarea:not([tabindex^="-"]), button:not([tabindex^="-"]), select:not([tabindex^="-"]), .ion-focusable:not([tabindex^="-"])' ;
15
16
16
17
/**
17
18
* @part container - The container for the menu content.
@@ -39,6 +40,11 @@ export class Menu implements ComponentInterface, MenuI {
39
40
backdropEl ?: HTMLElement ;
40
41
menuInnerEl ?: HTMLElement ;
41
42
contentEl ?: HTMLElement ;
43
+ lastFocus ?: HTMLElement ;
44
+
45
+ private inheritedAttributes : { [ k : string ] : any } = { } ;
46
+
47
+ private handleFocus = ( ev : Event ) => this . trapKeyboardFocus ( ev , document ) ;
42
48
43
49
@Element ( ) el ! : HTMLIonMenuElement ;
44
50
@@ -159,6 +165,7 @@ export class Menu implements ComponentInterface, MenuI {
159
165
160
166
const el = this . el ;
161
167
const parent = el . parentNode as any ;
168
+
162
169
if ( this . contentId === undefined ) {
163
170
console . warn ( `[DEPRECATED][ion-menu] Using the [main] attribute is deprecated, please use the "contentId" property instead:
164
171
BEFORE:
@@ -205,6 +212,10 @@ AFTER:
205
212
this . updateState ( ) ;
206
213
}
207
214
215
+ componentWillLoad ( ) {
216
+ this . inheritedAttributes = inheritAttributes ( this . el , [ 'aria-label' ] ) ;
217
+ }
218
+
208
219
async componentDidLoad ( ) {
209
220
this . ionMenuChange . emit ( { disabled : this . disabled , open : this . _isOpen } ) ;
210
221
this . updateState ( ) ;
@@ -246,6 +257,13 @@ AFTER:
246
257
}
247
258
}
248
259
260
+ @Listen ( 'keydown' )
261
+ onKeydown ( ev : KeyboardEvent ) {
262
+ if ( ev . key === 'Escape' ) {
263
+ this . close ( ) ;
264
+ }
265
+ }
266
+
249
267
/**
250
268
* Returns `true` is the menu is open.
251
269
*/
@@ -301,6 +319,65 @@ AFTER:
301
319
return menuController . _setOpen ( this , shouldOpen , animated ) ;
302
320
}
303
321
322
+ private focusFirstDescendant ( ) {
323
+ const { el } = this ;
324
+ const firstInput = el . querySelector ( focusableQueryString ) as HTMLElement | null ;
325
+
326
+ if ( firstInput ) {
327
+ firstInput . focus ( ) ;
328
+ } else {
329
+ el . focus ( ) ;
330
+ }
331
+ }
332
+
333
+ private focusLastDescendant ( ) {
334
+ const { el } = this ;
335
+ const inputs = Array . from ( el . querySelectorAll < HTMLElement > ( focusableQueryString ) ) ;
336
+ const lastInput = inputs . length > 0 ? inputs [ inputs . length - 1 ] : null ;
337
+
338
+ if ( lastInput ) {
339
+ lastInput . focus ( ) ;
340
+ } else {
341
+ el . focus ( ) ;
342
+ }
343
+ }
344
+
345
+ private trapKeyboardFocus ( ev : Event , doc : Document ) {
346
+ const target = ev . target as HTMLElement | null ;
347
+ if ( ! target ) { return ; }
348
+
349
+ /**
350
+ * If the target is inside the menu contents, let the browser
351
+ * focus as normal and keep a log of the last focused element.
352
+ */
353
+ if ( this . el . contains ( target ) ) {
354
+ this . lastFocus = target ;
355
+ } else {
356
+ /**
357
+ * Otherwise, we are about to have focus go out of the menu.
358
+ * Wrap the focus to either the first or last element.
359
+ */
360
+
361
+ /**
362
+ * Once we call `focusFirstDescendant`, another focus event
363
+ * will fire, which will cause `lastFocus` to be updated
364
+ * before we can run the code after that. We cache the value
365
+ * here to avoid that.
366
+ */
367
+ this . focusFirstDescendant ( ) ;
368
+
369
+ /**
370
+ * If the cached last focused element is the same as the now-
371
+ * active element, that means the user was on the first element
372
+ * already and pressed Shift + Tab, so we need to wrap to the
373
+ * last descendant.
374
+ */
375
+ if ( this . lastFocus === doc . activeElement ) {
376
+ this . focusLastDescendant ( ) ;
377
+ }
378
+ }
379
+ }
380
+
304
381
async _setOpen ( shouldOpen : boolean , animated = true ) : Promise < boolean > {
305
382
// If the menu is disabled or it is currently being animated, let's do nothing
306
383
if ( ! this . _isActive ( ) || this . isAnimating || shouldOpen === this . _isOpen ) {
@@ -479,6 +556,16 @@ AFTER:
479
556
// this places the menu into the correct location before it animates in
480
557
// this css class doesn't actually kick off any animations
481
558
this . el . classList . add ( SHOW_MENU ) ;
559
+
560
+ /**
561
+ * We add a tabindex here so that focus trapping
562
+ * still works even if the menu does not have
563
+ * any focusable elements slotted inside. The
564
+ * focus trapping utility will fallback to focusing
565
+ * the menu so focus does not leave when the menu
566
+ * is open.
567
+ */
568
+ this . el . setAttribute ( 'tabindex' , '0' ) ;
482
569
if ( this . backdropEl ) {
483
570
this . backdropEl . classList . add ( SHOW_BACKDROP ) ;
484
571
}
@@ -505,19 +592,51 @@ AFTER:
505
592
}
506
593
507
594
if ( isOpen ) {
508
- // add css class
595
+ // add css class and hide content behind menu from screen readers
509
596
if ( this . contentEl ) {
510
597
this . contentEl . classList . add ( MENU_CONTENT_OPEN ) ;
598
+
599
+ /**
600
+ * When the menu is open and overlaying the main
601
+ * content, the main content should not be announced
602
+ * by the screenreader as the menu is the main
603
+ * focus. This is useful with screenreaders that have
604
+ * "read from top" gestures that read the entire
605
+ * page from top to bottom when activated.
606
+ */
607
+ this . contentEl . setAttribute ( 'aria-hidden' , 'true' ) ;
511
608
}
512
609
513
610
// emit open event
514
611
this . ionDidOpen . emit ( ) ;
612
+
613
+ // focus menu content for screen readers
614
+ if ( this . menuInnerEl ) {
615
+ this . focusFirstDescendant ( ) ;
616
+ }
617
+
618
+ // setup focus trapping
619
+ document . addEventListener ( 'focus' , this . handleFocus , true ) ;
515
620
} else {
516
- // remove css classes
621
+ // remove css classes and unhide content from screen readers
517
622
this . el . classList . remove ( SHOW_MENU ) ;
623
+
624
+ /**
625
+ * Remove tabindex from the menu component
626
+ * so that is cannot be tabbed to.
627
+ */
628
+ this . el . removeAttribute ( 'tabindex' ) ;
518
629
if ( this . contentEl ) {
519
630
this . contentEl . classList . remove ( MENU_CONTENT_OPEN ) ;
631
+
632
+ /**
633
+ * Remove aria-hidden so screen readers
634
+ * can announce the main content again
635
+ * now that the menu is not the main focus.
636
+ */
637
+ this . contentEl . removeAttribute ( 'aria-hidden' ) ;
520
638
}
639
+
521
640
if ( this . backdropEl ) {
522
641
this . backdropEl . classList . remove ( SHOW_BACKDROP ) ;
523
642
}
@@ -528,6 +647,9 @@ AFTER:
528
647
529
648
// emit close event
530
649
this . ionDidClose . emit ( ) ;
650
+
651
+ // undo focus trapping so multiple menus don't collide
652
+ document . removeEventListener ( 'focus' , this . handleFocus , true ) ;
531
653
}
532
654
}
533
655
@@ -561,12 +683,13 @@ AFTER:
561
683
}
562
684
563
685
render ( ) {
564
- const { isEndSide, type, disabled, isPaneVisible } = this ;
686
+ const { isEndSide, type, disabled, isPaneVisible, inheritedAttributes } = this ;
565
687
const mode = getIonMode ( this ) ;
566
688
567
689
return (
568
690
< Host
569
691
role = "navigation"
692
+ aria-label = { inheritedAttributes [ 'aria-label' ] || 'menu' }
570
693
class = { {
571
694
[ mode ] : true ,
572
695
[ `menu-type-${ type } ` ] : true ,
0 commit comments