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} = { 149 top: null, 150 bottom: null, 151 }; 152 153 constructor( 154 @Inject(ChangeDetectorRef) private changeDetectorRef: ChangeDetectorRef, 155 @Inject(forwardRef(() => MatDrawerContainer)) 156 public container: MatDrawerContainer, 157 ) {} 158 159 ngAfterContentInit() { 160 this.container.contentMarginChanges.subscribe(() => { 161 this.changeDetectorRef.markForCheck(); 162 }); 163 } 164 165 setMargins(margins: {top: number | null; bottom: number | null}) { 166 this.contentMargins = margins; 167 } 168} 169 170/** 171 * Container for Material drawers 172 * @docs-private 173 */ 174@Component({ 175 selector: 'mat-drawer-container', 176 exportAs: 'matDrawerContainer', 177 template: ` 178 <ng-content select="mat-drawer-content"> </ng-content> 179 180 <ng-content select="mat-drawer"></ng-content> 181 `, 182 styles: [ 183 ` 184 .mat-drawer-container { 185 display: flex; 186 flex-direction: column; 187 flex-grow: 1; 188 align-items: center; 189 align-content: center; 190 justify-content: center; 191 } 192 `, 193 ], 194 host: { 195 class: 'mat-drawer-container', 196 }, 197 changeDetection: ChangeDetectionStrategy.OnPush, 198 encapsulation: ViewEncapsulation.None, 199}) 200@Injectable() 201export class MatDrawerContainer /*extends MatDrawerContainerBase*/ { 202 /** Drawer that belong to this container. */ 203 @ContentChild(MatDrawer) drawer!: MatDrawer; 204 @ContentChild(MatDrawer, {read: ElementRef}) drawerView!: ElementRef; 205 206 @ContentChild(MatDrawerContent) content!: MatDrawerContent; 207 @ViewChild(MatDrawerContent) userContent!: MatDrawerContent; 208 209 /** 210 * Margins to be applied to the content. These are used to push / shrink the drawer content when a 211 * drawer is open. We use margin rather than transform even for push mode because transform breaks 212 * fixed position elements inside of the transformed element. 213 */ 214 contentMargins: {top: number | null; bottom: number | null} = { 215 top: null, 216 bottom: null, 217 }; 218 219 readonly contentMarginChanges = new Subject<{ 220 top: number | null; 221 bottom: number | null; 222 }>(); 223 224 /** Emits on every ngDoCheck. Used for debouncing reflows. */ 225 private readonly doCheckSubject = new Subject<void>(); 226 227 /** Emits when the component is destroyed. */ 228 private readonly destroyed = new Subject<void>(); 229 230 constructor(@Inject(NgZone) private ngZone: NgZone) {} 231 232 ngAfterContentInit() { 233 this.updateContentMargins(); 234 235 // Avoid hitting the NgZone through the debounce timeout. 236 this.ngZone.runOutsideAngular(() => { 237 this.doCheckSubject 238 .pipe( 239 debounceTime(10), // Arbitrary debounce time, less than a frame at 60fps 240 takeUntil(this.destroyed), 241 ) 242 .subscribe(() => this.updateContentMargins()); 243 }); 244 } 245 246 ngOnDestroy() { 247 this.doCheckSubject.complete(); 248 this.destroyed.next(); 249 this.destroyed.complete(); 250 } 251 252 ngDoCheck() { 253 this.ngZone.runOutsideAngular(() => this.doCheckSubject.next()); 254 } 255 256 /** 257 * Recalculates and updates the inline styles for the content. Note that this should be used 258 * sparingly, because it causes a reflow. 259 */ 260 updateContentMargins() { 261 // If shift is enabled want to shift the content without resizing it. We do 262 // this by adding to the top or bottom margin and simultaneously subtracting 263 // the same amount of margin from the other side. 264 let top = 0; 265 let bottom = 0; 266 267 const baseHeight = this.drawer.getBaseHeight(); 268 const height = this.getDrawerHeight(); 269 const shiftAmount = 270 this.drawer.mode === 'push' ? Math.max(0, height - baseHeight) : 0; 271 272 top -= shiftAmount; 273 bottom += baseHeight + shiftAmount; 274 275 // If either `top` or `bottom` is zero, don't set a style to the element. This 276 // allows users to specify a custom size via CSS class in SSR scenarios where the 277 // measured widths will always be zero. Note that we reset to `null` here, rather 278 // than below, in order to ensure that the types in the `if` below are consistent. 279 top = top || null!; 280 bottom = bottom || null!; 281 282 if ( 283 top !== this.contentMargins.top || 284 bottom !== this.contentMargins.bottom 285 ) { 286 this.contentMargins = {top, bottom}; 287 288 this.content.setMargins(this.contentMargins); 289 290 // Pull back into the NgZone since in some cases we could be outside. We need to be careful 291 // to do it only when something changed, otherwise we can end up hitting the zone too often. 292 this.ngZone.run(() => 293 this.contentMarginChanges.next(this.contentMargins), 294 ); 295 } 296 } 297 298 getDrawerHeight(): number { 299 return this.drawerView.nativeElement 300 ? this.drawerView.nativeElement.offsetHeight || 0 301 : 0; 302 } 303} 304