Skip to content

Commit 2f1e180

Browse files
committed
fix(sidenav): end position sidenav tab order not matching visual order
We project all sidenavs before the content in the DOM since we can't know ahead of time what their position will be. This is problematic when the drawer is in the end position, because the visual order of the content no longer matches the tab order. These changes fix the issue by moving the sidenav after the content manually when it's set to `end` and then moving it back if it's set to `start` again. A couple of notes: 1. We could technically do this with content projection, but it would only work when the `position` value is static (e.g. `position="end"`). I did it this way so we can cover the case where it's data bound. 2. Currently the focus trap anchors are set around the sidenav, but that's problematic when we're moving the element around since the anchors will be left at their old positions. To avoid adding extra logic for moving the anchors, I've moved the focus trap to be inside the sidenav. Here's what the DOM looks like at the moment: ```html <container> <anchor/> <sidenav>Content</sidenav> <anchor/> </container> ``` And this is what I've changed it to: ```html <container> <sidenav> <anchor/> Content <anchor/> </sidenav> </container ``` Fixes #15247.
1 parent 09dc459 commit 2f1e180

File tree

4 files changed

+201
-13
lines changed

4 files changed

+201
-13
lines changed

src/material/sidenav/drawer.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
<div class="mat-drawer-inner-container">
1+
<div class="mat-drawer-inner-container" #content>
22
<ng-content></ng-content>
33
</div>

src/material/sidenav/drawer.spec.ts

+133-1
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,111 @@ describe('MatDrawer', () => {
592592
}));
593593

594594
});
595+
596+
describe('DOM position', () => {
597+
it('should project start drawer before the content', () => {
598+
const fixture = TestBed.createComponent(BasicTestApp);
599+
fixture.componentInstance.position = 'start';
600+
fixture.detectChanges();
601+
602+
const allNodes = getDrawerNodesArray(fixture);
603+
const drawerIndex = allNodes.indexOf(fixture.nativeElement.querySelector('.mat-drawer'));
604+
const contentIndex =
605+
allNodes.indexOf(fixture.nativeElement.querySelector('.mat-drawer-content'));
606+
607+
expect(drawerIndex).toBeGreaterThan(-1, 'Expected drawer to be inside the container');
608+
expect(contentIndex).toBeGreaterThan(-1, 'Expected content to be inside the container');
609+
expect(drawerIndex).toBeLessThan(contentIndex, 'Expected drawer to be before the content');
610+
});
611+
612+
it('should project end drawer after the content', () => {
613+
const fixture = TestBed.createComponent(BasicTestApp);
614+
fixture.componentInstance.position = 'end';
615+
fixture.detectChanges();
616+
617+
const allNodes = getDrawerNodesArray(fixture);
618+
const drawerIndex = allNodes.indexOf(fixture.nativeElement.querySelector('.mat-drawer'));
619+
const contentIndex =
620+
allNodes.indexOf(fixture.nativeElement.querySelector('.mat-drawer-content'));
621+
622+
expect(drawerIndex).toBeGreaterThan(-1, 'Expected drawer to be inside the container');
623+
expect(contentIndex).toBeGreaterThan(-1, 'Expected content to be inside the container');
624+
expect(drawerIndex).toBeGreaterThan(contentIndex, 'Expected drawer to be after the content');
625+
});
626+
627+
it('should move the drawer before/after the content when its position changes after being ' +
628+
'initialized at `start`', () => {
629+
const fixture = TestBed.createComponent(BasicTestApp);
630+
fixture.componentInstance.position = 'start';
631+
fixture.detectChanges();
632+
633+
const drawer = fixture.nativeElement.querySelector('.mat-drawer');
634+
const content = fixture.nativeElement.querySelector('.mat-drawer-content');
635+
636+
let allNodes = getDrawerNodesArray(fixture);
637+
const startDrawerIndex = allNodes.indexOf(drawer);
638+
const startContentIndex = allNodes.indexOf(content);
639+
640+
expect(startDrawerIndex).toBeGreaterThan(-1, 'Expected drawer to be inside the container');
641+
expect(startContentIndex)
642+
.toBeGreaterThan(-1, 'Expected content to be inside the container');
643+
expect(startDrawerIndex)
644+
.toBeLessThan(startContentIndex, 'Expected drawer to be before the content on init');
645+
646+
fixture.componentInstance.position = 'end';
647+
fixture.detectChanges();
648+
allNodes = getDrawerNodesArray(fixture);
649+
650+
expect(allNodes.indexOf(drawer)).toBeGreaterThan(allNodes.indexOf(content),
651+
'Expected drawer to be after content when position changes to `end`');
652+
653+
fixture.componentInstance.position = 'start';
654+
fixture.detectChanges();
655+
allNodes = getDrawerNodesArray(fixture);
656+
657+
expect(allNodes.indexOf(drawer)).toBeLessThan(allNodes.indexOf(content),
658+
'Expected drawer to be before content when position changes back to `start`');
659+
});
660+
661+
it('should move the drawer before/after the content when its position changes after being ' +
662+
'initialized at `end`', () => {
663+
const fixture = TestBed.createComponent(BasicTestApp);
664+
fixture.componentInstance.position = 'end';
665+
fixture.detectChanges();
666+
667+
const drawer = fixture.nativeElement.querySelector('.mat-drawer');
668+
const content = fixture.nativeElement.querySelector('.mat-drawer-content');
669+
670+
let allNodes = getDrawerNodesArray(fixture);
671+
const startDrawerIndex = allNodes.indexOf(drawer);
672+
const startContentIndex = allNodes.indexOf(content);
673+
674+
expect(startDrawerIndex).toBeGreaterThan(-1, 'Expected drawer to be inside the container');
675+
expect(startContentIndex)
676+
.toBeGreaterThan(-1, 'Expected content to be inside the container');
677+
expect(startDrawerIndex)
678+
.toBeGreaterThan(startContentIndex, 'Expected drawer to be after the content on init');
679+
680+
fixture.componentInstance.position = 'start';
681+
fixture.detectChanges();
682+
allNodes = getDrawerNodesArray(fixture);
683+
684+
expect(allNodes.indexOf(drawer)).toBeLessThan(allNodes.indexOf(content),
685+
'Expected drawer to be before content when position changes to `start`');
686+
687+
fixture.componentInstance.position = 'end';
688+
fixture.detectChanges();
689+
allNodes = getDrawerNodesArray(fixture);
690+
691+
expect(allNodes.indexOf(drawer)).toBeGreaterThan(allNodes.indexOf(content),
692+
'Expected drawer to be after content when position changes back to `end`');
693+
});
694+
695+
function getDrawerNodesArray(fixture: ComponentFixture<any>): HTMLElement[] {
696+
return Array.from(fixture.nativeElement.querySelector('.mat-drawer-container').childNodes);
697+
}
698+
699+
});
595700
});
596701

597702
describe('MatDrawerContainer', () => {
@@ -875,6 +980,32 @@ describe('MatDrawerContainer', () => {
875980
expect(spy).toHaveBeenCalled();
876981
subscription.unsubscribe();
877982
}));
983+
984+
it('should position the drawers before/after the content in the DOM based on their position',
985+
fakeAsync(() => {
986+
const fixture = TestBed.createComponent(DrawerContainerTwoDrawerTestApp);
987+
fixture.detectChanges();
988+
989+
const drawerDebugElements = fixture.debugElement.queryAll(By.directive(MatDrawer));
990+
const [start, end] = drawerDebugElements.map(el => el.componentInstance);
991+
const [startNode, endNode] = drawerDebugElements.map(el => el.nativeElement);
992+
const contentNode = fixture.nativeElement.querySelector('.mat-drawer-content');
993+
const allNodes: HTMLElement[] =
994+
Array.from(fixture.nativeElement.querySelector('.mat-drawer-container').childNodes);
995+
const startIndex = allNodes.indexOf(startNode);
996+
const endIndex = allNodes.indexOf(endNode);
997+
const contentIndex = allNodes.indexOf(contentNode);
998+
999+
expect(start.position).toBe('start');
1000+
expect(end.position).toBe('end');
1001+
expect(contentIndex).toBeGreaterThan(-1, 'Expected content to be inside the container');
1002+
expect(startIndex).toBeGreaterThan(-1, 'Expected start drawer to be inside the container');
1003+
expect(endIndex).toBeGreaterThan(-1, 'Expected end drawer to be inside the container');
1004+
1005+
expect(startIndex).toBeLessThan(contentIndex, 'Expected start drawer to be before content');
1006+
expect(endIndex).toBeGreaterThan(contentIndex, 'Expected end drawer to be after content');
1007+
}));
1008+
8781009
});
8791010

8801011

@@ -898,7 +1029,7 @@ class DrawerContainerTwoDrawerTestApp {
8981029
@Component({
8991030
template: `
9001031
<mat-drawer-container (backdropClick)="backdropClicked()" [hasBackdrop]="hasBackdrop">
901-
<mat-drawer #drawer="matDrawer" position="start"
1032+
<mat-drawer #drawer="matDrawer" [position]="position"
9021033
(opened)="open()"
9031034
(openedStart)="openStart()"
9041035
(closed)="close()"
@@ -916,6 +1047,7 @@ class BasicTestApp {
9161047
closeStartCount = 0;
9171048
backdropClickedCount = 0;
9181049
hasBackdrop: boolean | null = null;
1050+
position = 'start';
9191051

9201052
@ViewChild('drawer') drawer: MatDrawer;
9211053
@ViewChild('drawerButton') drawerButton: ElementRef<HTMLButtonElement>;

src/material/sidenav/drawer.ts

+63-8
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
ViewEncapsulation,
3838
HostListener,
3939
HostBinding,
40+
AfterViewInit,
4041
} from '@angular/core';
4142
import {fromEvent, merge, Observable, Subject} from 'rxjs';
4243
import {
@@ -137,20 +138,32 @@ export class MatDrawerContent extends CdkScrollable implements AfterContentInit
137138
changeDetection: ChangeDetectionStrategy.OnPush,
138139
encapsulation: ViewEncapsulation.None,
139140
})
140-
export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestroy {
141+
export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy {
141142
private _focusTrap: FocusTrap;
142143
private _elementFocusedBeforeDrawerWasOpened: HTMLElement | null = null;
144+
private _document: Document;
143145

144146
/** Whether the drawer is initialized. Used for disabling the initial animation. */
145147
private _enableAnimations = false;
146148

149+
/** Whether the view of the component has been attached. */
150+
private _isAttached: boolean;
151+
152+
/** Anchor node used to restore the drawer to its initial position. */
153+
private _anchor: Comment | null;
154+
147155
/** The side that the drawer is attached to. */
148156
@Input()
149157
get position(): 'start' | 'end' { return this._position; }
150158
set position(value: 'start' | 'end') {
151159
// Make sure we have a valid value.
152160
value = value === 'end' ? 'end' : 'start';
153-
if (value != this._position) {
161+
if (value !== this._position) {
162+
// Static inputs in Ivy are set before the element is in the DOM.
163+
if (this._isAttached) {
164+
this._updatePositionInParent(value);
165+
}
166+
154167
this._position = value;
155168
this.onPositionChanged.emit();
156169
}
@@ -258,6 +271,9 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr
258271
// tslint:disable-next-line:no-output-on-prefix
259272
@Output('positionChanged') onPositionChanged: EventEmitter<void> = new EventEmitter<void>();
260273

274+
/** Reference to the inner element that contains all the content. */
275+
@ViewChild('content') _content: ElementRef<HTMLElement>;
276+
261277
/**
262278
* An observable that emits when the drawer mode changes. This is used by the drawer container to
263279
* to know when to when the mode changes so it can adapt the margins on the content.
@@ -269,17 +285,18 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr
269285
private _focusMonitor: FocusMonitor,
270286
private _platform: Platform,
271287
private _ngZone: NgZone,
272-
@Optional() @Inject(DOCUMENT) private _doc: any,
288+
@Optional() @Inject(DOCUMENT) _document: any,
273289
/**
274290
* @deprecated `_container` parameter to be made required.
275291
* @breaking-change 10.0.0
276292
*/
277293
@Optional() @Inject(MAT_DRAWER_CONTAINER) public _container?: MatDrawerContainer) {
278294

295+
this._document = _document;
279296
this.openedChange.subscribe((opened: boolean) => {
280297
if (opened) {
281-
if (this._doc) {
282-
this._elementFocusedBeforeDrawerWasOpened = this._doc.activeElement as HTMLElement;
298+
if (this._document) {
299+
this._elementFocusedBeforeDrawerWasOpened = this._document.activeElement as HTMLElement;
283300
}
284301

285302
this._takeFocus();
@@ -347,7 +364,7 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr
347364
return;
348365
}
349366

350-
const activeEl = this._doc && this._doc.activeElement;
367+
const activeEl = this._document && this._document.activeElement;
351368

352369
if (activeEl && this._elementRef.nativeElement.contains(activeEl)) {
353370
if (this._elementFocusedBeforeDrawerWasOpened instanceof HTMLElement) {
@@ -361,9 +378,20 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr
361378
this._openedVia = null;
362379
}
363380

364-
ngAfterContentInit() {
365-
this._focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement);
381+
ngAfterViewInit() {
382+
this._isAttached = true;
383+
384+
// Note that we attach the focus trap to the content element, rather than the component host
385+
// so that we can move the host to a different place in the DOM without leaving the focus trap
386+
// anchors behind.
387+
this._focusTrap = this._focusTrapFactory.create(this._content.nativeElement);
366388
this._updateFocusTrapState();
389+
390+
// Only update the DOM position when the sidenav is positioned at
391+
// the end since we project the sidenav before the content by default.
392+
if (this._position === 'end') {
393+
this._updatePositionInParent('end');
394+
}
367395
}
368396

369397
ngAfterContentChecked() {
@@ -381,6 +409,11 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr
381409
this._focusTrap.destroy();
382410
}
383411

412+
if (this._anchor && this._anchor.parentNode) {
413+
this._anchor.parentNode.removeChild(this._anchor);
414+
}
415+
416+
this._anchor = null;
384417
this._animationStarted.complete();
385418
this._animationEnd.complete();
386419
this._modeChanged.complete();
@@ -440,6 +473,28 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr
440473
}
441474
}
442475

476+
/**
477+
* Updates the position of the drawer in the DOM. We need to move the element around ourselves
478+
* when it's in the `end` position so that it comes after the content and the visual order
479+
* matches the tab order. We also need to be able to move it back to `start` if the sidenav
480+
* started off as `end` and was changed to `start`.
481+
*/
482+
private _updatePositionInParent(newPosition: 'start' | 'end') {
483+
const element = this._elementRef.nativeElement;
484+
const parent = element.parentNode!;
485+
486+
if (newPosition === 'end') {
487+
if (!this._anchor) {
488+
this._anchor = this._document.createComment('mat-drawer-anchor');
489+
parent.insertBefore(this._anchor, element);
490+
}
491+
492+
parent.appendChild(element);
493+
} else if (this._anchor) {
494+
this._anchor.parentNode!.insertBefore(element, this._anchor);
495+
}
496+
}
497+
443498
// We have to use a `HostListener` here in order to support both Ivy and ViewEngine.
444499
// In Ivy the `host` bindings will be merged when this class is extended, whereas in
445500
// ViewEngine they're overwritten.

tools/public_api_guard/material/sidenav.d.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ export declare const MAT_DRAWER_DEFAULT_AUTOSIZE: InjectionToken<boolean>;
22

33
export declare function MAT_DRAWER_DEFAULT_AUTOSIZE_FACTORY(): boolean;
44

5-
export declare class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestroy {
5+
export declare class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy {
66
_animationEnd: Subject<AnimationEvent>;
77
_animationStarted: Subject<AnimationEvent>;
88
_animationState: 'open-instant' | 'open' | 'void';
99
readonly _closedStream: Observable<void>;
1010
_container?: MatDrawerContainer | undefined;
11+
_content: ElementRef<HTMLElement>;
1112
readonly _modeChanged: Subject<void>;
1213
readonly _openedStream: Observable<void>;
1314
readonly _width: number;
@@ -20,13 +21,13 @@ export declare class MatDrawer implements AfterContentInit, AfterContentChecked,
2021
readonly openedChange: EventEmitter<boolean>;
2122
readonly openedStart: Observable<void>;
2223
position: 'start' | 'end';
23-
constructor(_elementRef: ElementRef<HTMLElement>, _focusTrapFactory: FocusTrapFactory, _focusMonitor: FocusMonitor, _platform: Platform, _ngZone: NgZone, _doc: any,
24+
constructor(_elementRef: ElementRef<HTMLElement>, _focusTrapFactory: FocusTrapFactory, _focusMonitor: FocusMonitor, _platform: Platform, _ngZone: NgZone, _document: any,
2425
_container?: MatDrawerContainer | undefined);
2526
_animationDoneListener(event: AnimationEvent): void;
2627
_animationStartListener(event: AnimationEvent): void;
2728
close(): Promise<MatDrawerToggleResult>;
2829
ngAfterContentChecked(): void;
29-
ngAfterContentInit(): void;
30+
ngAfterViewInit(): void;
3031
ngOnDestroy(): void;
3132
open(openedVia?: FocusOrigin): Promise<MatDrawerToggleResult>;
3233
toggle(isOpen?: boolean, openedVia?: FocusOrigin): Promise<MatDrawerToggleResult>;

0 commit comments

Comments
 (0)