diff --git a/src/material/sidenav/drawer.html b/src/material/sidenav/drawer.html index 9e7cb52395ba..5743adea2ce9 100644 --- a/src/material/sidenav/drawer.html +++ b/src/material/sidenav/drawer.html @@ -1,3 +1,3 @@ -
+
diff --git a/src/material/sidenav/drawer.spec.ts b/src/material/sidenav/drawer.spec.ts index 14fa3d669d72..e84572f6d992 100644 --- a/src/material/sidenav/drawer.spec.ts +++ b/src/material/sidenav/drawer.spec.ts @@ -658,6 +658,110 @@ describe('MatDrawer', () => { expect(scrollable.getElementRef().nativeElement).toBe(content.nativeElement); }); + describe('DOM position', () => { + it('should project start drawer before the content', () => { + const fixture = TestBed.createComponent(BasicTestApp); + fixture.componentInstance.position = 'start'; + fixture.detectChanges(); + + const allNodes = getDrawerNodesArray(fixture); + const drawerIndex = allNodes.indexOf(fixture.nativeElement.querySelector('.mat-drawer')); + const contentIndex = + allNodes.indexOf(fixture.nativeElement.querySelector('.mat-drawer-content')); + + expect(drawerIndex).toBeGreaterThan(-1, 'Expected drawer to be inside the container'); + expect(contentIndex).toBeGreaterThan(-1, 'Expected content to be inside the container'); + expect(drawerIndex).toBeLessThan(contentIndex, 'Expected drawer to be before the content'); + }); + + it('should project end drawer after the content', () => { + const fixture = TestBed.createComponent(BasicTestApp); + fixture.componentInstance.position = 'end'; + fixture.detectChanges(); + + const allNodes = getDrawerNodesArray(fixture); + const drawerIndex = allNodes.indexOf(fixture.nativeElement.querySelector('.mat-drawer')); + const contentIndex = + allNodes.indexOf(fixture.nativeElement.querySelector('.mat-drawer-content')); + + expect(drawerIndex).toBeGreaterThan(-1, 'Expected drawer to be inside the container'); + expect(contentIndex).toBeGreaterThan(-1, 'Expected content to be inside the container'); + expect(drawerIndex).toBeGreaterThan(contentIndex, 'Expected drawer to be after the content'); + }); + + it('should move the drawer before/after the content when its position changes after being ' + + 'initialized at `start`', () => { + const fixture = TestBed.createComponent(BasicTestApp); + fixture.componentInstance.position = 'start'; + fixture.detectChanges(); + + const drawer = fixture.nativeElement.querySelector('.mat-drawer'); + const content = fixture.nativeElement.querySelector('.mat-drawer-content'); + + let allNodes = getDrawerNodesArray(fixture); + const startDrawerIndex = allNodes.indexOf(drawer); + const startContentIndex = allNodes.indexOf(content); + + expect(startDrawerIndex).toBeGreaterThan(-1, 'Expected drawer to be inside the container'); + expect(startContentIndex) + .toBeGreaterThan(-1, 'Expected content to be inside the container'); + expect(startDrawerIndex) + .toBeLessThan(startContentIndex, 'Expected drawer to be before the content on init'); + + fixture.componentInstance.position = 'end'; + fixture.detectChanges(); + allNodes = getDrawerNodesArray(fixture); + + expect(allNodes.indexOf(drawer)).toBeGreaterThan(allNodes.indexOf(content), + 'Expected drawer to be after content when position changes to `end`'); + + fixture.componentInstance.position = 'start'; + fixture.detectChanges(); + allNodes = getDrawerNodesArray(fixture); + + expect(allNodes.indexOf(drawer)).toBeLessThan(allNodes.indexOf(content), + 'Expected drawer to be before content when position changes back to `start`'); + }); + + it('should move the drawer before/after the content when its position changes after being ' + + 'initialized at `end`', () => { + const fixture = TestBed.createComponent(BasicTestApp); + fixture.componentInstance.position = 'end'; + fixture.detectChanges(); + + const drawer = fixture.nativeElement.querySelector('.mat-drawer'); + const content = fixture.nativeElement.querySelector('.mat-drawer-content'); + + let allNodes = getDrawerNodesArray(fixture); + const startDrawerIndex = allNodes.indexOf(drawer); + const startContentIndex = allNodes.indexOf(content); + + expect(startDrawerIndex).toBeGreaterThan(-1, 'Expected drawer to be inside the container'); + expect(startContentIndex) + .toBeGreaterThan(-1, 'Expected content to be inside the container'); + expect(startDrawerIndex) + .toBeGreaterThan(startContentIndex, 'Expected drawer to be after the content on init'); + + fixture.componentInstance.position = 'start'; + fixture.detectChanges(); + allNodes = getDrawerNodesArray(fixture); + + expect(allNodes.indexOf(drawer)).toBeLessThan(allNodes.indexOf(content), + 'Expected drawer to be before content when position changes to `start`'); + + fixture.componentInstance.position = 'end'; + fixture.detectChanges(); + allNodes = getDrawerNodesArray(fixture); + + expect(allNodes.indexOf(drawer)).toBeGreaterThan(allNodes.indexOf(content), + 'Expected drawer to be after content when position changes back to `end`'); + }); + + function getDrawerNodesArray(fixture: ComponentFixture): HTMLElement[] { + return Array.from(fixture.nativeElement.querySelector('.mat-drawer-container').childNodes); + } + + }); }); describe('MatDrawerContainer', () => { @@ -943,6 +1047,32 @@ describe('MatDrawerContainer', () => { expect(spy).toHaveBeenCalled(); subscription.unsubscribe(); })); + + it('should position the drawers before/after the content in the DOM based on their position', + fakeAsync(() => { + const fixture = TestBed.createComponent(DrawerContainerTwoDrawerTestApp); + fixture.detectChanges(); + + const drawerDebugElements = fixture.debugElement.queryAll(By.directive(MatDrawer)); + const [start, end] = drawerDebugElements.map(el => el.componentInstance); + const [startNode, endNode] = drawerDebugElements.map(el => el.nativeElement); + const contentNode = fixture.nativeElement.querySelector('.mat-drawer-content'); + const allNodes: HTMLElement[] = + Array.from(fixture.nativeElement.querySelector('.mat-drawer-container').childNodes); + const startIndex = allNodes.indexOf(startNode); + const endIndex = allNodes.indexOf(endNode); + const contentIndex = allNodes.indexOf(contentNode); + + expect(start.position).toBe('start'); + expect(end.position).toBe('end'); + expect(contentIndex).toBeGreaterThan(-1, 'Expected content to be inside the container'); + expect(startIndex).toBeGreaterThan(-1, 'Expected start drawer to be inside the container'); + expect(endIndex).toBeGreaterThan(-1, 'Expected end drawer to be inside the container'); + + expect(startIndex).toBeLessThan(contentIndex, 'Expected start drawer to be before content'); + expect(endIndex).toBeGreaterThan(contentIndex, 'Expected end drawer to be after content'); + })); + }); @@ -966,7 +1096,7 @@ class DrawerContainerTwoDrawerTestApp { @Component({ template: ` - ; diff --git a/src/material/sidenav/drawer.ts b/src/material/sidenav/drawer.ts index 90776445316e..1ffc33483932 100644 --- a/src/material/sidenav/drawer.ts +++ b/src/material/sidenav/drawer.ts @@ -43,6 +43,7 @@ import { ViewEncapsulation, HostListener, HostBinding, + AfterViewInit, } from '@angular/core'; import {fromEvent, merge, Observable, Subject} from 'rxjs'; import { @@ -146,20 +147,31 @@ export class MatDrawerContent extends CdkScrollable implements AfterContentInit changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, }) -export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestroy { +export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy { private _focusTrap: FocusTrap; private _elementFocusedBeforeDrawerWasOpened: HTMLElement | null = null; /** Whether the drawer is initialized. Used for disabling the initial animation. */ private _enableAnimations = false; + /** Whether the view of the component has been attached. */ + private _isAttached: boolean; + + /** Anchor node used to restore the drawer to its initial position. */ + private _anchor: Comment | null; + /** The side that the drawer is attached to. */ @Input() get position(): 'start' | 'end' { return this._position; } set position(value: 'start' | 'end') { // Make sure we have a valid value. value = value === 'end' ? 'end' : 'start'; - if (value != this._position) { + if (value !== this._position) { + // Static inputs in Ivy are set before the element is in the DOM. + if (this._isAttached) { + this._updatePositionInParent(value); + } + this._position = value; this.onPositionChanged.emit(); } @@ -273,6 +285,9 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr // tslint:disable-next-line:no-output-on-prefix @Output('positionChanged') readonly onPositionChanged = new EventEmitter(); + /** Reference to the inner element that contains all the content. */ + @ViewChild('content') _content: ElementRef; + /** * An observable that emits when the drawer mode changes. This is used by the drawer container to * to know when to when the mode changes so it can adapt the margins on the content. @@ -418,13 +433,20 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr /** Whether focus is currently within the drawer. */ private _isFocusWithinDrawer(): boolean { - const activeEl = this._doc?.activeElement; + const activeEl = this._doc.activeElement; return !!activeEl && this._elementRef.nativeElement.contains(activeEl); } - ngAfterContentInit() { + ngAfterViewInit() { + this._isAttached = true; this._focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement); this._updateFocusTrapState(); + + // Only update the DOM position when the sidenav is positioned at + // the end since we project the sidenav before the content by default. + if (this._position === 'end') { + this._updatePositionInParent('end'); + } } ngAfterContentChecked() { @@ -442,6 +464,11 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr this._focusTrap.destroy(); } + if (this._anchor && this._anchor.parentNode) { + this._anchor.parentNode.removeChild(this._anchor); + } + + this._anchor = null; this._animationStarted.complete(); this._animationEnd.complete(); this._modeChanged.complete(); @@ -525,6 +552,28 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr } } + /** + * Updates the position of the drawer in the DOM. We need to move the element around ourselves + * when it's in the `end` position so that it comes after the content and the visual order + * matches the tab order. We also need to be able to move it back to `start` if the sidenav + * started off as `end` and was changed to `start`. + */ + private _updatePositionInParent(newPosition: 'start' | 'end') { + const element = this._elementRef.nativeElement; + const parent = element.parentNode!; + + if (newPosition === 'end') { + if (!this._anchor) { + this._anchor = this._doc.createComment('mat-drawer-anchor')!; + parent.insertBefore(this._anchor!, element); + } + + parent.appendChild(element); + } else if (this._anchor) { + this._anchor.parentNode!.insertBefore(element, this._anchor); + } + } + // We have to use a `HostListener` here in order to support both Ivy and ViewEngine. // In Ivy the `host` bindings will be merged when this class is extended, whereas in // ViewEngine they're overwritten. diff --git a/tools/public_api_guard/material/sidenav.md b/tools/public_api_guard/material/sidenav.md index b1cf9df053e6..58ddc076585f 100644 --- a/tools/public_api_guard/material/sidenav.md +++ b/tools/public_api_guard/material/sidenav.md @@ -6,6 +6,7 @@ import { AfterContentChecked } from '@angular/core'; import { AfterContentInit } from '@angular/core'; +import { AfterViewInit } from '@angular/core'; import { AnimationEvent as AnimationEvent_2 } from '@angular/animations'; import { AnimationTriggerMetadata } from '@angular/animations'; import { BooleanInput } from '@angular/cdk/coercion'; @@ -48,7 +49,7 @@ export const MAT_DRAWER_DEFAULT_AUTOSIZE: InjectionToken; export function MAT_DRAWER_DEFAULT_AUTOSIZE_FACTORY(): boolean; // @public -export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestroy { +export class MatDrawer implements AfterViewInit, AfterContentChecked, OnDestroy { constructor(_elementRef: ElementRef, _focusTrapFactory: FocusTrapFactory, _focusMonitor: FocusMonitor, _platform: Platform, _ngZone: NgZone, _interactivityChecker: InteractivityChecker, _doc: any, _container?: MatDrawerContainer | undefined); // (undocumented) _animationDoneListener(event: AnimationEvent_2): void; @@ -65,6 +66,7 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr _closeViaBackdropClick(): Promise; // (undocumented) _container?: MatDrawerContainer | undefined; + _content: ElementRef; get disableClose(): boolean; set disableClose(value: boolean); // (undocumented) @@ -81,7 +83,7 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr // (undocumented) ngAfterContentChecked(): void; // (undocumented) - ngAfterContentInit(): void; + ngAfterViewInit(): void; // (undocumented) ngOnDestroy(): void; readonly onPositionChanged: EventEmitter;