1/* 2 * Copyright (C) 2022 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17import { 18 animate, 19 AnimationTriggerMetadata, 20 state, 21 style, 22 transition, 23 trigger, 24} from '@angular/animations'; 25import { 26 ChangeDetectionStrategy, 27 ChangeDetectorRef, 28 Component, 29 ContentChild, 30 ElementRef, 31 forwardRef, 32 Inject, 33 Injectable, 34 Input, 35 NgZone, 36 ViewChild, 37 ViewEncapsulation, 38} from '@angular/core'; 39import {Subject} from 'rxjs'; 40import {debounceTime, takeUntil} from 'rxjs/operators'; 41 42/** 43 * Animations used by the Material drawers. 44 * @docs-private 45 */ 46export const matDrawerAnimations: { 47 readonly transformDrawer: AnimationTriggerMetadata; 48} = { 49 /** Animation that slides a drawer in and out. */ 50 transformDrawer: trigger('transform', [ 51 // We remove the `transform` here completely, rather than setting it to zero, because: 52 // 1. Having a transform can cause elements with ripples or an animated 53 // transform to shift around in Chrome with an RTL layout (see #10023). 54 // 2. 3d transforms causes text to appear blurry on IE and Edge. 55 state( 56 'open, open-instant', 57 style({ 58 transform: 'none', 59 visibility: 'visible', 60 }) 61 ), 62 state( 63 'void', 64 style({ 65 // Avoids the shadow showing up when closed in SSR. 66 'box-shadow': 'none', 67 visibility: 'hidden', 68 }) 69 ), 70 transition('void => open-instant', animate('0ms')), 71 transition( 72 'void <=> open, open-instant => void', 73 animate('400ms cubic-bezier(0.25, 0.8, 0.25, 1)') 74 ), 75 ]), 76}; 77 78/** 79 * This component corresponds to a drawer that can be opened on the drawer container. 80 */ 81@Injectable() 82@Component({ 83 selector: 'mat-drawer', 84 exportAs: 'matDrawer', 85 template: ` 86 <div class="mat-drawer-inner-container" #content> 87 <ng-content></ng-content> 88 </div> 89 `, 90 styles: [ 91 ` 92 .mat-drawer.mat-drawer-bottom { 93 left: 0; 94 right: 0; 95 bottom: 0; 96 top: unset; 97 position: fixed; 98 z-index: 5; 99 background-color: #f8f9fa; 100 box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.3), 0px 1px 3px 1px rgba(0, 0, 0, 0.15); 101 } 102 `, 103 ], 104 animations: [matDrawerAnimations.transformDrawer], 105 host: { 106 class: 'mat-drawer mat-drawer-bottom', 107 // must prevent the browser from aligning text based on value 108 '[attr.align]': 'null', 109 }, 110 changeDetection: ChangeDetectionStrategy.OnPush, 111 encapsulation: ViewEncapsulation.None, 112}) 113export class MatDrawer { 114 @Input() mode: 'push' | 'overlay' = 'overlay'; 115 @Input() baseHeight = 0; 116 117 getBaseHeight() { 118 return this.baseHeight; 119 } 120} 121 122@Component({ 123 selector: 'mat-drawer-content', 124 template: '<ng-content></ng-content>', 125 styles: [ 126 ` 127 .mat-drawer-content { 128 display: flex; 129 flex-direction: column; 130 position: relative; 131 z-index: 1; 132 height: unset; 133 overflow: unset; 134 width: 100%; 135 flex-grow: 1; 136 } 137 `, 138 ], 139 host: { 140 class: 'mat-drawer-content', 141 '[style.margin-top.px]': 'contentMargins.top', 142 '[style.margin-bottom.px]': 'contentMargins.bottom', 143 }, 144 changeDetection: ChangeDetectionStrategy.OnPush, 145 encapsulation: ViewEncapsulation.None, 146}) 147export class MatDrawerContent /*extends MatDrawerContentBase*/ { 148 private contentMargins: {top: number | null; bottom: number | null} = {top: null, bottom: null}; 149 150 constructor( 151 @Inject(ChangeDetectorRef) private changeDetectorRef: ChangeDetectorRef, 152 @Inject(forwardRef(() => MatDrawerContainer)) public container: MatDrawerContainer 153 ) {} 154 155 ngAfterContentInit() { 156 this.container.contentMarginChanges.subscribe(() => { 157 this.changeDetectorRef.markForCheck(); 158 }); 159 } 160 161 setMargins(margins: {top: number | null; bottom: number | null}) { 162 this.contentMargins = margins; 163 } 164} 165 166@Component({ 167 selector: 'mat-drawer-container', 168 exportAs: 'matDrawerContainer', 169 template: ` 170 <ng-content select="mat-drawer-content"> </ng-content> 171 172 <ng-content select="mat-drawer"></ng-content> 173 `, 174 styles: [ 175 ` 176 .mat-drawer-container { 177 display: flex; 178 flex-direction: column; 179 flex-grow: 1; 180 align-items: center; 181 align-content: center; 182 justify-content: center; 183 } 184 `, 185 ], 186 host: { 187 class: 'mat-drawer-container', 188 }, 189 changeDetection: ChangeDetectionStrategy.OnPush, 190 encapsulation: ViewEncapsulation.None, 191}) 192@Injectable() 193export class MatDrawerContainer /*extends MatDrawerContainerBase*/ { 194 /** Drawer that belong to this container. */ 195 @ContentChild(MatDrawer) drawer!: MatDrawer; 196 @ContentChild(MatDrawer, {read: ElementRef}) drawerView!: ElementRef; 197 198 @ContentChild(MatDrawerContent) content!: MatDrawerContent; 199 @ViewChild(MatDrawerContent) userContent!: MatDrawerContent; 200 201 /** 202 * Margins to be applied to the content. These are used to push / shrink the drawer content when a 203 * drawer is open. We use margin rather than transform even for push mode because transform breaks 204 * fixed position elements inside of the transformed element. 205 */ 206 contentMargins: {top: number | null; bottom: number | null} = {top: null, bottom: null}; 207 208 readonly contentMarginChanges = new Subject<{top: number | null; bottom: number | null}>(); 209 210 /** Emits on every ngDoCheck. Used for debouncing reflows. */ 211 private readonly doCheckSubject = new Subject<void>(); 212 213 /** Emits when the component is destroyed. */ 214 private readonly destroyed = new Subject<void>(); 215 216 constructor(@Inject(NgZone) private ngZone: NgZone) {} 217 218 ngAfterContentInit() { 219 this.updateContentMargins(); 220 221 // Avoid hitting the NgZone through the debounce timeout. 222 this.ngZone.runOutsideAngular(() => { 223 this.doCheckSubject 224 .pipe( 225 debounceTime(10), // Arbitrary debounce time, less than a frame at 60fps 226 takeUntil(this.destroyed) 227 ) 228 .subscribe(() => this.updateContentMargins()); 229 }); 230 } 231 232 ngOnDestroy() { 233 this.doCheckSubject.complete(); 234 this.destroyed.next(); 235 this.destroyed.complete(); 236 } 237 238 ngDoCheck() { 239 this.ngZone.runOutsideAngular(() => this.doCheckSubject.next()); 240 } 241 242 /** 243 * Recalculates and updates the inline styles for the content. Note that this should be used 244 * sparingly, because it causes a reflow. 245 */ 246 updateContentMargins() { 247 // If shift is enabled want to shift the content without resizing it. We do 248 // this by adding to the top or bottom margin and simultaneously subtracting 249 // the same amount of margin from the other side. 250 let top = 0; 251 let bottom = 0; 252 253 const baseHeight = this.drawer.getBaseHeight(); 254 const height = this.getDrawerHeight(); 255 const shiftAmount = this.drawer.mode === 'push' ? Math.max(0, height - baseHeight) : 0; 256 257 top -= shiftAmount; 258 bottom += baseHeight + shiftAmount; 259 260 // If either `top` or `bottom` is zero, don't set a style to the element. This 261 // allows users to specify a custom size via CSS class in SSR scenarios where the 262 // measured widths will always be zero. Note that we reset to `null` here, rather 263 // than below, in order to ensure that the types in the `if` below are consistent. 264 top = top || null!; 265 bottom = bottom || null!; 266 267 if (top !== this.contentMargins.top || bottom !== this.contentMargins.bottom) { 268 this.contentMargins = {top, bottom}; 269 270 this.content.setMargins(this.contentMargins); 271 272 // Pull back into the NgZone since in some cases we could be outside. We need to be careful 273 // to do it only when something changed, otherwise we can end up hitting the zone too often. 274 this.ngZone.run(() => this.contentMarginChanges.next(this.contentMargins)); 275 } 276 } 277 278 getDrawerHeight(): number { 279 return this.drawerView.nativeElement ? this.drawerView.nativeElement.offsetHeight || 0 : 0; 280 } 281} 282