Skip to content

Commit 98f62e9

Browse files
committed
fix(material/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 0d3a730 commit 98f62e9

File tree

4 files changed

+230
-8
lines changed

4 files changed

+230
-8
lines changed

src/material/sidenav/drawer.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
<div class="mat-drawer-inner-container" cdkScrollable>
1+
<div class="mat-drawer-inner-container" cdkScrollable #content>
22
<ng-content></ng-content>
33
</div>

src/material/sidenav/drawer.spec.ts

Lines changed: 175 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,144 @@ describe('MatDrawer', () => {
661661
expect(scrollable).toBeTruthy();
662662
expect(scrollable.getElementRef().nativeElement).toBe(content.nativeElement);
663663
});
664+
665+
describe('DOM position', () => {
666+
it('should project start drawer before the content', () => {
667+
const fixture = TestBed.createComponent(BasicTestApp);
668+
fixture.componentInstance.position = 'start';
669+
fixture.detectChanges();
670+
671+
const allNodes = getDrawerNodesArray(fixture);
672+
const drawerIndex = allNodes.indexOf(fixture.nativeElement.querySelector('.mat-drawer'));
673+
const contentIndex = allNodes.indexOf(
674+
fixture.nativeElement.querySelector('.mat-drawer-content'),
675+
);
676+
677+
expect(drawerIndex)
678+
.withContext('Expected drawer to be inside the container')
679+
.toBeGreaterThan(-1);
680+
expect(contentIndex)
681+
.withContext('Expected content to be inside the container')
682+
.toBeGreaterThan(-1);
683+
expect(drawerIndex)
684+
.withContext('Expected drawer to be before the content')
685+
.toBeLessThan(contentIndex);
686+
});
687+
688+
it('should project end drawer after the content', () => {
689+
const fixture = TestBed.createComponent(BasicTestApp);
690+
fixture.componentInstance.position = 'end';
691+
fixture.detectChanges();
692+
693+
const allNodes = getDrawerNodesArray(fixture);
694+
const drawerIndex = allNodes.indexOf(fixture.nativeElement.querySelector('.mat-drawer'));
695+
const contentIndex = allNodes.indexOf(
696+
fixture.nativeElement.querySelector('.mat-drawer-content'),
697+
);
698+
699+
expect(drawerIndex)
700+
.withContext('Expected drawer to be inside the container')
701+
.toBeGreaterThan(-1);
702+
expect(contentIndex)
703+
.withContext('Expected content to be inside the container')
704+
.toBeGreaterThan(-1);
705+
expect(drawerIndex)
706+
.withContext('Expected drawer to be after the content')
707+
.toBeGreaterThan(contentIndex);
708+
});
709+
710+
it(
711+
'should move the drawer before/after the content when its position changes after being ' +
712+
'initialized at `start`',
713+
() => {
714+
const fixture = TestBed.createComponent(BasicTestApp);
715+
fixture.componentInstance.position = 'start';
716+
fixture.detectChanges();
717+
718+
const drawer = fixture.nativeElement.querySelector('.mat-drawer');
719+
const content = fixture.nativeElement.querySelector('.mat-drawer-content');
720+
721+
let allNodes = getDrawerNodesArray(fixture);
722+
const startDrawerIndex = allNodes.indexOf(drawer);
723+
const startContentIndex = allNodes.indexOf(content);
724+
725+
expect(startDrawerIndex)
726+
.withContext('Expected drawer to be inside the container')
727+
.toBeGreaterThan(-1);
728+
expect(startContentIndex)
729+
.withContext('Expected content to be inside the container')
730+
.toBeGreaterThan(-1);
731+
expect(startDrawerIndex)
732+
.withContext('Expected drawer to be before the content on init')
733+
.toBeLessThan(startContentIndex);
734+
735+
fixture.componentInstance.position = 'end';
736+
fixture.detectChanges();
737+
allNodes = getDrawerNodesArray(fixture);
738+
739+
expect(allNodes.indexOf(drawer))
740+
.withContext('Expected drawer to be after content when position changes to `end`')
741+
.toBeGreaterThan(allNodes.indexOf(content));
742+
743+
fixture.componentInstance.position = 'start';
744+
fixture.detectChanges();
745+
allNodes = getDrawerNodesArray(fixture);
746+
747+
expect(allNodes.indexOf(drawer))
748+
.withContext('Expected drawer to be before content when position changes back to `start`')
749+
.toBeLessThan(allNodes.indexOf(content));
750+
},
751+
);
752+
753+
it(
754+
'should move the drawer before/after the content when its position changes after being ' +
755+
'initialized at `end`',
756+
() => {
757+
const fixture = TestBed.createComponent(BasicTestApp);
758+
fixture.componentInstance.position = 'end';
759+
fixture.detectChanges();
760+
761+
const drawer = fixture.nativeElement.querySelector('.mat-drawer');
762+
const content = fixture.nativeElement.querySelector('.mat-drawer-content');
763+
764+
let allNodes = getDrawerNodesArray(fixture);
765+
const startDrawerIndex = allNodes.indexOf(drawer);
766+
const startContentIndex = allNodes.indexOf(content);
767+
768+
expect(startDrawerIndex).toBeGreaterThan(-1, 'Expected drawer to be inside the container');
769+
expect(startContentIndex).toBeGreaterThan(
770+
-1,
771+
'Expected content to be inside the container',
772+
);
773+
expect(startDrawerIndex).toBeGreaterThan(
774+
startContentIndex,
775+
'Expected drawer to be after the content on init',
776+
);
777+
778+
fixture.componentInstance.position = 'start';
779+
fixture.detectChanges();
780+
allNodes = getDrawerNodesArray(fixture);
781+
782+
expect(allNodes.indexOf(drawer)).toBeLessThan(
783+
allNodes.indexOf(content),
784+
'Expected drawer to be before content when position changes to `start`',
785+
);
786+
787+
fixture.componentInstance.position = 'end';
788+
fixture.detectChanges();
789+
allNodes = getDrawerNodesArray(fixture);
790+
791+
expect(allNodes.indexOf(drawer)).toBeGreaterThan(
792+
allNodes.indexOf(content),
793+
'Expected drawer to be after content when position changes back to `end`',
794+
);
795+
},
796+
);
797+
798+
function getDrawerNodesArray(fixture: ComponentFixture<any>): HTMLElement[] {
799+
return Array.from(fixture.nativeElement.querySelector('.mat-drawer-container').childNodes);
800+
}
801+
});
664802
});
665803

666804
describe('MatDrawerContainer', () => {
@@ -949,6 +1087,41 @@ describe('MatDrawerContainer', () => {
9491087
expect(spy).toHaveBeenCalled();
9501088
subscription.unsubscribe();
9511089
}));
1090+
1091+
it('should position the drawers before/after the content in the DOM based on their position', fakeAsync(() => {
1092+
const fixture = TestBed.createComponent(DrawerContainerTwoDrawerTestApp);
1093+
fixture.detectChanges();
1094+
1095+
const drawerDebugElements = fixture.debugElement.queryAll(By.directive(MatDrawer));
1096+
const [start, end] = drawerDebugElements.map(el => el.componentInstance);
1097+
const [startNode, endNode] = drawerDebugElements.map(el => el.nativeElement);
1098+
const contentNode = fixture.nativeElement.querySelector('.mat-drawer-content');
1099+
const allNodes: HTMLElement[] = Array.from(
1100+
fixture.nativeElement.querySelector('.mat-drawer-container').childNodes,
1101+
);
1102+
const startIndex = allNodes.indexOf(startNode);
1103+
const endIndex = allNodes.indexOf(endNode);
1104+
const contentIndex = allNodes.indexOf(contentNode);
1105+
1106+
expect(start.position).toBe('start');
1107+
expect(end.position).toBe('end');
1108+
expect(contentIndex)
1109+
.withContext('Expected content to be inside the container')
1110+
.toBeGreaterThan(-1);
1111+
expect(startIndex)
1112+
.withContext('Expected start drawer to be inside the container')
1113+
.toBeGreaterThan(-1);
1114+
expect(endIndex)
1115+
.withContext('Expected end drawer to be inside the container')
1116+
.toBeGreaterThan(-1);
1117+
1118+
expect(startIndex)
1119+
.withContext('Expected start drawer to be before content')
1120+
.toBeLessThan(contentIndex);
1121+
expect(endIndex)
1122+
.withContext('Expected end drawer to be after content')
1123+
.toBeGreaterThan(contentIndex);
1124+
}));
9521125
});
9531126

9541127
/** Test component that contains an MatDrawerContainer but no MatDrawer. */
@@ -971,7 +1144,7 @@ class DrawerContainerTwoDrawerTestApp {
9711144
@Component({
9721145
template: `
9731146
<mat-drawer-container (backdropClick)="backdropClicked()" [hasBackdrop]="hasBackdrop">
974-
<mat-drawer #drawer="matDrawer" position="start"
1147+
<mat-drawer #drawer="matDrawer" [position]="position"
9751148
(opened)="open()"
9761149
(openedStart)="openStart()"
9771150
(closed)="close()"
@@ -997,6 +1170,7 @@ class BasicTestApp {
9971170
closeStartCount = 0;
9981171
backdropClickedCount = 0;
9991172
hasBackdrop: boolean | null = null;
1173+
position = 'start';
10001174

10011175
@ViewChild('drawer') drawer: MatDrawer;
10021176
@ViewChild('drawerButton') drawerButton: ElementRef<HTMLButtonElement>;

src/material/sidenav/drawer.ts

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {DOCUMENT} from '@angular/common';
2222
import {
2323
AfterContentChecked,
2424
AfterContentInit,
25+
AfterViewInit,
2526
ChangeDetectionStrategy,
2627
ChangeDetectorRef,
2728
Component,
@@ -147,13 +148,19 @@ export class MatDrawerContent extends CdkScrollable implements AfterContentInit
147148
changeDetection: ChangeDetectionStrategy.OnPush,
148149
encapsulation: ViewEncapsulation.None,
149150
})
150-
export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestroy {
151+
export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy {
151152
private _focusTrap: FocusTrap;
152153
private _elementFocusedBeforeDrawerWasOpened: HTMLElement | null = null;
153154

154155
/** Whether the drawer is initialized. Used for disabling the initial animation. */
155156
private _enableAnimations = false;
156157

158+
/** Whether the view of the component has been attached. */
159+
private _isAttached: boolean;
160+
161+
/** Anchor node used to restore the drawer to its initial position. */
162+
private _anchor: Comment | null;
163+
157164
/** The side that the drawer is attached to. */
158165
@Input()
159166
get position(): 'start' | 'end' {
@@ -162,7 +169,12 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr
162169
set position(value: 'start' | 'end') {
163170
// Make sure we have a valid value.
164171
value = value === 'end' ? 'end' : 'start';
165-
if (value != this._position) {
172+
if (value !== this._position) {
173+
// Static inputs in Ivy are set before the element is in the DOM.
174+
if (this._isAttached) {
175+
this._updatePositionInParent(value);
176+
}
177+
166178
this._position = value;
167179
this.onPositionChanged.emit();
168180
}
@@ -287,6 +299,9 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr
287299
// tslint:disable-next-line:no-output-on-prefix
288300
@Output('positionChanged') readonly onPositionChanged = new EventEmitter<void>();
289301

302+
/** Reference to the inner element that contains all the content. */
303+
@ViewChild('content') _content: ElementRef<HTMLElement>;
304+
290305
/**
291306
* An observable that emits when the drawer mode changes. This is used by the drawer container to
292307
* to know when to when the mode changes so it can adapt the margins on the content.
@@ -442,13 +457,20 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr
442457

443458
/** Whether focus is currently within the drawer. */
444459
private _isFocusWithinDrawer(): boolean {
445-
const activeEl = this._doc?.activeElement;
460+
const activeEl = this._doc.activeElement;
446461
return !!activeEl && this._elementRef.nativeElement.contains(activeEl);
447462
}
448463

449-
ngAfterContentInit() {
464+
ngAfterViewInit() {
465+
this._isAttached = true;
450466
this._focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement);
451467
this._updateFocusTrapState();
468+
469+
// Only update the DOM position when the sidenav is positioned at
470+
// the end since we project the sidenav before the content by default.
471+
if (this._position === 'end') {
472+
this._updatePositionInParent('end');
473+
}
452474
}
453475

454476
ngAfterContentChecked() {
@@ -466,6 +488,8 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr
466488
this._focusTrap.destroy();
467489
}
468490

491+
this._anchor?.remove();
492+
this._anchor = null;
469493
this._animationStarted.complete();
470494
this._animationEnd.complete();
471495
this._modeChanged.complete();
@@ -562,6 +586,28 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr
562586
}
563587
}
564588

589+
/**
590+
* Updates the position of the drawer in the DOM. We need to move the element around ourselves
591+
* when it's in the `end` position so that it comes after the content and the visual order
592+
* matches the tab order. We also need to be able to move it back to `start` if the sidenav
593+
* started off as `end` and was changed to `start`.
594+
*/
595+
private _updatePositionInParent(newPosition: 'start' | 'end') {
596+
const element = this._elementRef.nativeElement;
597+
const parent = element.parentNode!;
598+
599+
if (newPosition === 'end') {
600+
if (!this._anchor) {
601+
this._anchor = this._doc.createComment('mat-drawer-anchor')!;
602+
parent.insertBefore(this._anchor!, element);
603+
}
604+
605+
parent.appendChild(element);
606+
} else if (this._anchor) {
607+
this._anchor.parentNode!.insertBefore(element, this._anchor);
608+
}
609+
}
610+
565611
static ngAcceptInputType_disableClose: BooleanInput;
566612
static ngAcceptInputType_autoFocus: AutoFocusTarget | string | BooleanInput;
567613
static ngAcceptInputType_opened: BooleanInput;

tools/public_api_guard/material/sidenav.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import { AfterContentChecked } from '@angular/core';
88
import { AfterContentInit } from '@angular/core';
9+
import { AfterViewInit } from '@angular/core';
910
import { AnimationEvent as AnimationEvent_2 } from '@angular/animations';
1011
import { AnimationTriggerMetadata } from '@angular/animations';
1112
import { BooleanInput } from '@angular/cdk/coercion';
@@ -48,7 +49,7 @@ export const MAT_DRAWER_DEFAULT_AUTOSIZE: InjectionToken<boolean>;
4849
export function MAT_DRAWER_DEFAULT_AUTOSIZE_FACTORY(): boolean;
4950

5051
// @public
51-
export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestroy {
52+
export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy {
5253
constructor(_elementRef: ElementRef<HTMLElement>, _focusTrapFactory: FocusTrapFactory, _focusMonitor: FocusMonitor, _platform: Platform, _ngZone: NgZone, _interactivityChecker: InteractivityChecker, _doc: any, _container?: MatDrawerContainer | undefined);
5354
readonly _animationEnd: Subject<AnimationEvent_2>;
5455
readonly _animationStarted: Subject<AnimationEvent_2>;
@@ -61,6 +62,7 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr
6162
_closeViaBackdropClick(): Promise<MatDrawerToggleResult>;
6263
// (undocumented)
6364
_container?: MatDrawerContainer | undefined;
65+
_content: ElementRef<HTMLElement>;
6466
get disableClose(): boolean;
6567
set disableClose(value: boolean);
6668
// (undocumented)
@@ -77,7 +79,7 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr
7779
// (undocumented)
7880
ngAfterContentChecked(): void;
7981
// (undocumented)
80-
ngAfterContentInit(): void;
82+
ngAfterViewInit(): void;
8183
// (undocumented)
8284
ngOnDestroy(): void;
8385
readonly onPositionChanged: EventEmitter<void>;

0 commit comments

Comments
 (0)