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