-
Notifications
You must be signed in to change notification settings - Fork 6.8k
/
Copy pathdrawer.ts
1067 lines (931 loc) · 35.3 KB
/
drawer.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {
FocusMonitor,
FocusOrigin,
FocusTrap,
FocusTrapFactory,
InteractivityChecker,
} from '@angular/cdk/a11y';
import {Directionality} from '@angular/cdk/bidi';
import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion';
import {ESCAPE, hasModifierKey} from '@angular/cdk/keycodes';
import {Platform} from '@angular/cdk/platform';
import {CdkScrollable, ScrollDispatcher, ViewportRuler} from '@angular/cdk/scrolling';
import {DOCUMENT} from '@angular/common';
import {
AfterContentInit,
afterNextRender,
AfterViewInit,
ANIMATION_MODULE_TYPE,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ContentChild,
ContentChildren,
DoCheck,
ElementRef,
EventEmitter,
inject,
InjectionToken,
Injector,
Input,
NgZone,
OnDestroy,
Output,
QueryList,
Renderer2,
ViewChild,
ViewEncapsulation,
} from '@angular/core';
import {fromEvent, merge, Observable, Subject} from 'rxjs';
import {debounceTime, filter, map, mapTo, startWith, take, takeUntil} from 'rxjs/operators';
/**
* Throws an exception when two MatDrawer are matching the same position.
* @docs-private
*/
export function throwMatDuplicatedDrawerError(position: string) {
throw Error(`A drawer was already declared for 'position="${position}"'`);
}
/** Options for where to set focus to automatically on dialog open */
export type AutoFocusTarget = 'dialog' | 'first-tabbable' | 'first-heading';
/** Result of the toggle promise that indicates the state of the drawer. */
export type MatDrawerToggleResult = 'open' | 'close';
/** Drawer and SideNav display modes. */
export type MatDrawerMode = 'over' | 'push' | 'side';
/** Configures whether drawers should use auto sizing by default. */
export const MAT_DRAWER_DEFAULT_AUTOSIZE = new InjectionToken<boolean>(
'MAT_DRAWER_DEFAULT_AUTOSIZE',
{
providedIn: 'root',
factory: MAT_DRAWER_DEFAULT_AUTOSIZE_FACTORY,
},
);
/**
* Used to provide a drawer container to a drawer while avoiding circular references.
* @docs-private
*/
export const MAT_DRAWER_CONTAINER = new InjectionToken('MAT_DRAWER_CONTAINER');
/** @docs-private */
export function MAT_DRAWER_DEFAULT_AUTOSIZE_FACTORY(): boolean {
return false;
}
@Component({
selector: 'mat-drawer-content',
template: '<ng-content></ng-content>',
host: {
'class': 'mat-drawer-content',
'[style.margin-left.px]': '_container._contentMargins.left',
'[style.margin-right.px]': '_container._contentMargins.right',
'[class.mat-drawer-content-hidden]': '_shouldBeHidden()',
},
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
providers: [
{
provide: CdkScrollable,
useExisting: MatDrawerContent,
},
],
})
export class MatDrawerContent extends CdkScrollable implements AfterContentInit {
private _platform = inject(Platform);
private _changeDetectorRef = inject(ChangeDetectorRef);
_container = inject(MatDrawerContainer);
constructor(...args: unknown[]);
constructor() {
const elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
const scrollDispatcher = inject(ScrollDispatcher);
const ngZone = inject(NgZone);
super(elementRef, scrollDispatcher, ngZone);
}
ngAfterContentInit() {
this._container._contentMarginChanges.subscribe(() => {
this._changeDetectorRef.markForCheck();
});
}
/** Determines whether the content element should be hidden from the user. */
protected _shouldBeHidden(): boolean {
// In some modes the content is pushed based on the width of the opened sidenavs, however on
// the server we can't measure the sidenav so the margin is always zero. This can cause the
// content to jump around when it's rendered on the server and hydrated on the client. We
// avoid it by hiding the content on the initial render and then showing it once the sidenav
// has been measured on the client.
if (this._platform.isBrowser) {
return false;
}
const {start, end} = this._container;
return (
(start != null && start.mode !== 'over' && start.opened) ||
(end != null && end.mode !== 'over' && end.opened)
);
}
}
/**
* This component corresponds to a drawer that can be opened on the drawer container.
*/
@Component({
selector: 'mat-drawer',
exportAs: 'matDrawer',
templateUrl: 'drawer.html',
host: {
'class': 'mat-drawer',
// must prevent the browser from aligning text based on value
'[attr.align]': 'null',
'[class.mat-drawer-end]': 'position === "end"',
'[class.mat-drawer-over]': 'mode === "over"',
'[class.mat-drawer-push]': 'mode === "push"',
'[class.mat-drawer-side]': 'mode === "side"',
// The styles that render the sidenav off-screen come from the drawer container. Prior to #30235
// this was also done by the animations module which some internal tests seem to depend on.
// Simulate it by toggling the `hidden` attribute instead.
'[style.visibility]': '(!_container && !opened) ? "hidden" : null',
'tabIndex': '-1',
},
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
imports: [CdkScrollable],
})
export class MatDrawer implements AfterViewInit, OnDestroy {
private _elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
private _focusTrapFactory = inject(FocusTrapFactory);
private _focusMonitor = inject(FocusMonitor);
private _platform = inject(Platform);
private _ngZone = inject(NgZone);
private _renderer = inject(Renderer2);
private readonly _interactivityChecker = inject(InteractivityChecker);
private _doc = inject(DOCUMENT, {optional: true})!;
_container? = inject<MatDrawerContainer>(MAT_DRAWER_CONTAINER, {optional: true});
private _focusTrap: FocusTrap | null = null;
private _elementFocusedBeforeDrawerWasOpened: HTMLElement | null = null;
private _eventCleanups: (() => void)[];
/** 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) {
// 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();
}
}
private _position: 'start' | 'end' = 'start';
/** Mode of the drawer; one of 'over', 'push' or 'side'. */
@Input()
get mode(): MatDrawerMode {
return this._mode;
}
set mode(value: MatDrawerMode) {
this._mode = value;
this._updateFocusTrapState();
this._modeChanged.next();
}
private _mode: MatDrawerMode = 'over';
/** Whether the drawer can be closed with the escape key or by clicking on the backdrop. */
@Input()
get disableClose(): boolean {
return this._disableClose;
}
set disableClose(value: BooleanInput) {
this._disableClose = coerceBooleanProperty(value);
}
private _disableClose: boolean = false;
/**
* Whether the drawer should focus the first focusable element automatically when opened.
* Defaults to false in when `mode` is set to `side`, otherwise defaults to `true`. If explicitly
* enabled, focus will be moved into the sidenav in `side` mode as well.
* @breaking-change 14.0.0 Remove boolean option from autoFocus. Use string or AutoFocusTarget
* instead.
*/
@Input()
get autoFocus(): AutoFocusTarget | string | boolean {
const value = this._autoFocus;
// Note that usually we don't allow autoFocus to be set to `first-tabbable` in `side` mode,
// because we don't know how the sidenav is being used, but in some cases it still makes
// sense to do it. The consumer can explicitly set `autoFocus`.
if (value == null) {
if (this.mode === 'side') {
return 'dialog';
} else {
return 'first-tabbable';
}
}
return value;
}
set autoFocus(value: AutoFocusTarget | string | BooleanInput) {
if (value === 'true' || value === 'false' || value == null) {
value = coerceBooleanProperty(value);
}
this._autoFocus = value;
}
private _autoFocus: AutoFocusTarget | string | boolean | undefined;
/**
* Whether the drawer is opened. We overload this because we trigger an event when it
* starts or end.
*/
@Input()
get opened(): boolean {
return this._opened;
}
set opened(value: BooleanInput) {
this.toggle(coerceBooleanProperty(value));
}
private _opened: boolean = false;
/** How the sidenav was opened (keypress, mouse click etc.) */
private _openedVia: FocusOrigin | null;
/** Emits whenever the drawer has started animating. */
readonly _animationStarted = new Subject();
/** Emits whenever the drawer is done animating. */
readonly _animationEnd = new Subject();
/** Event emitted when the drawer open state is changed. */
@Output() readonly openedChange: EventEmitter<boolean> =
// Note this has to be async in order to avoid some issues with two-bindings (see #8872).
new EventEmitter<boolean>(/* isAsync */ true);
/** Event emitted when the drawer has been opened. */
@Output('opened')
readonly _openedStream = this.openedChange.pipe(
filter(o => o),
map(() => {}),
);
/** Event emitted when the drawer has started opening. */
@Output()
readonly openedStart: Observable<void> = this._animationStarted.pipe(
filter(() => this.opened),
mapTo(undefined),
);
/** Event emitted when the drawer has been closed. */
@Output('closed')
readonly _closedStream = this.openedChange.pipe(
filter(o => !o),
map(() => {}),
);
/** Event emitted when the drawer has started closing. */
@Output()
readonly closedStart: Observable<void> = this._animationStarted.pipe(
filter(() => !this.opened),
mapTo(undefined),
);
/** Emits when the component is destroyed. */
private readonly _destroyed = new Subject<void>();
/** Event emitted when the drawer's position changes. */
// tslint:disable-next-line:no-output-on-prefix
@Output('positionChanged') readonly onPositionChanged = new EventEmitter<void>();
/** Reference to the inner element that contains all the content. */
@ViewChild('content') _content: ElementRef<HTMLElement>;
/**
* 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.
*/
readonly _modeChanged = new Subject<void>();
private _injector = inject(Injector);
private _changeDetectorRef = inject(ChangeDetectorRef);
constructor(...args: unknown[]);
constructor() {
this.openedChange.pipe(takeUntil(this._destroyed)).subscribe((opened: boolean) => {
if (opened) {
if (this._doc) {
this._elementFocusedBeforeDrawerWasOpened = this._doc.activeElement as HTMLElement;
}
this._takeFocus();
} else if (this._isFocusWithinDrawer()) {
this._restoreFocus(this._openedVia || 'program');
}
});
/**
* Listen to `keydown` events outside the zone so that change detection is not run every
* time a key is pressed. Instead we re-enter the zone only if the `ESC` key is pressed
* and we don't have close disabled.
*/
this._ngZone.runOutsideAngular(() => {
const element = this._elementRef.nativeElement;
(fromEvent(element, 'keydown') as Observable<KeyboardEvent>)
.pipe(
filter(event => {
return event.keyCode === ESCAPE && !this.disableClose && !hasModifierKey(event);
}),
takeUntil(this._destroyed),
)
.subscribe(event =>
this._ngZone.run(() => {
this.close();
event.stopPropagation();
event.preventDefault();
}),
);
this._eventCleanups = [
this._renderer.listen(element, 'transitionrun', this._handleTransitionEvent),
this._renderer.listen(element, 'transitionend', this._handleTransitionEvent),
this._renderer.listen(element, 'transitioncancel', this._handleTransitionEvent),
];
});
this._animationEnd.subscribe(() => {
this.openedChange.emit(this._opened);
});
}
/**
* Focuses the provided element. If the element is not focusable, it will add a tabIndex
* attribute to forcefully focus it. The attribute is removed after focus is moved.
* @param element The element to focus.
*/
private _forceFocus(element: HTMLElement, options?: FocusOptions) {
if (!this._interactivityChecker.isFocusable(element)) {
element.tabIndex = -1;
// The tabindex attribute should be removed to avoid navigating to that element again
this._ngZone.runOutsideAngular(() => {
const callback = () => {
cleanupBlur();
cleanupMousedown();
element.removeAttribute('tabindex');
};
const cleanupBlur = this._renderer.listen(element, 'blur', callback);
const cleanupMousedown = this._renderer.listen(element, 'mousedown', callback);
});
}
element.focus(options);
}
/**
* Focuses the first element that matches the given selector within the focus trap.
* @param selector The CSS selector for the element to set focus to.
*/
private _focusByCssSelector(selector: string, options?: FocusOptions) {
let elementToFocus = this._elementRef.nativeElement.querySelector(
selector,
) as HTMLElement | null;
if (elementToFocus) {
this._forceFocus(elementToFocus, options);
}
}
/**
* Moves focus into the drawer. Note that this works even if
* the focus trap is disabled in `side` mode.
*/
private _takeFocus() {
if (!this._focusTrap) {
return;
}
const element = this._elementRef.nativeElement;
// When autoFocus is not on the sidenav, if the element cannot be focused or does
// not exist, focus the sidenav itself so the keyboard navigation still works.
// We need to check that `focus` is a function due to Universal.
switch (this.autoFocus) {
case false:
case 'dialog':
return;
case true:
case 'first-tabbable':
afterNextRender(
() => {
const hasMovedFocus = this._focusTrap!.focusInitialElement();
if (!hasMovedFocus && typeof element.focus === 'function') {
element.focus();
}
},
{injector: this._injector},
);
break;
case 'first-heading':
this._focusByCssSelector('h1, h2, h3, h4, h5, h6, [role="heading"]');
break;
default:
this._focusByCssSelector(this.autoFocus!);
break;
}
}
/**
* Restores focus to the element that was originally focused when the drawer opened.
* If no element was focused at that time, the focus will be restored to the drawer.
*/
private _restoreFocus(focusOrigin: Exclude<FocusOrigin, null>) {
if (this.autoFocus === 'dialog') {
return;
}
if (this._elementFocusedBeforeDrawerWasOpened) {
this._focusMonitor.focusVia(this._elementFocusedBeforeDrawerWasOpened, focusOrigin);
} else {
this._elementRef.nativeElement.blur();
}
this._elementFocusedBeforeDrawerWasOpened = null;
}
/** Whether focus is currently within the drawer. */
private _isFocusWithinDrawer(): boolean {
const activeEl = this._doc.activeElement;
return !!activeEl && this._elementRef.nativeElement.contains(activeEl);
}
ngAfterViewInit() {
this._isAttached = true;
// 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');
}
// Needs to happen after the position is updated
// so the focus trap anchors are in the right place.
if (this._platform.isBrowser) {
this._focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement);
this._updateFocusTrapState();
}
}
ngOnDestroy() {
this._eventCleanups.forEach(cleanup => cleanup());
this._focusTrap?.destroy();
this._anchor?.remove();
this._anchor = null;
this._animationStarted.complete();
this._animationEnd.complete();
this._modeChanged.complete();
this._destroyed.next();
this._destroyed.complete();
}
/**
* Open the drawer.
* @param openedVia Whether the drawer was opened by a key press, mouse click or programmatically.
* Used for focus management after the sidenav is closed.
*/
open(openedVia?: FocusOrigin): Promise<MatDrawerToggleResult> {
return this.toggle(true, openedVia);
}
/** Close the drawer. */
close(): Promise<MatDrawerToggleResult> {
return this.toggle(false);
}
/** Closes the drawer with context that the backdrop was clicked. */
_closeViaBackdropClick(): Promise<MatDrawerToggleResult> {
// If the drawer is closed upon a backdrop click, we always want to restore focus. We
// don't need to check whether focus is currently in the drawer, as clicking on the
// backdrop causes blurs the active element.
return this._setOpen(/* isOpen */ false, /* restoreFocus */ true, 'mouse');
}
/**
* Toggle this drawer.
* @param isOpen Whether the drawer should be open.
* @param openedVia Whether the drawer was opened by a key press, mouse click or programmatically.
* Used for focus management after the sidenav is closed.
*/
toggle(isOpen: boolean = !this.opened, openedVia?: FocusOrigin): Promise<MatDrawerToggleResult> {
// If the focus is currently inside the drawer content and we are closing the drawer,
// restore the focus to the initially focused element (when the drawer opened).
if (isOpen && openedVia) {
this._openedVia = openedVia;
}
const result = this._setOpen(
isOpen,
/* restoreFocus */ !isOpen && this._isFocusWithinDrawer(),
this._openedVia || 'program',
);
if (!isOpen) {
this._openedVia = null;
}
return result;
}
/**
* Toggles the opened state of the drawer.
* @param isOpen Whether the drawer should open or close.
* @param restoreFocus Whether focus should be restored on close.
* @param focusOrigin Origin to use when restoring focus.
*/
private _setOpen(
isOpen: boolean,
restoreFocus: boolean,
focusOrigin: Exclude<FocusOrigin, null>,
): Promise<MatDrawerToggleResult> {
if (isOpen === this._opened) {
return Promise.resolve(isOpen ? 'open' : 'close');
}
this._opened = isOpen;
if (this._container?._transitionsEnabled) {
// Note: it's importatnt to set this as early as possible,
// otherwise the animation can look glitchy in some cases.
this._setIsAnimating(true);
} else {
// Simulate the animation events if animations are disabled.
setTimeout(() => {
this._animationStarted.next();
this._animationEnd.next();
});
}
this._elementRef.nativeElement.classList.toggle('mat-drawer-opened', isOpen);
if (!isOpen && restoreFocus) {
this._restoreFocus(focusOrigin);
}
// Needed to ensure that the closing sequence fires off correctly.
this._changeDetectorRef.markForCheck();
this._updateFocusTrapState();
return new Promise<MatDrawerToggleResult>(resolve => {
this.openedChange.pipe(take(1)).subscribe(open => resolve(open ? 'open' : 'close'));
});
}
/** Toggles whether the drawer is currently animating. */
private _setIsAnimating(isAnimating: boolean) {
this._elementRef.nativeElement.classList.toggle('mat-drawer-animating', isAnimating);
}
_getWidth(): number {
return this._elementRef.nativeElement.offsetWidth || 0;
}
/** Updates the enabled state of the focus trap. */
private _updateFocusTrapState() {
if (this._focusTrap) {
// Trap focus only if the backdrop is enabled. Otherwise, allow end user to interact with the
// sidenav content.
this._focusTrap.enabled = !!this._container?.hasBackdrop && this.opened;
}
}
/**
* 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'): void {
// Don't move the DOM node around on the server, because it can throw off hydration.
if (!this._platform.isBrowser) {
return;
}
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);
}
}
/** Event handler for animation events. */
private _handleTransitionEvent = (event: TransitionEvent) => {
const element = this._elementRef.nativeElement;
if (event.target === element) {
this._ngZone.run(() => {
if (event.type === 'transitionrun') {
this._animationStarted.next(event);
} else {
// Don't toggle the animating state on `transitioncancel` since another animation should
// start afterwards. This prevents the drawer from blinking if an animation is interrupted.
if (event.type === 'transitionend') {
this._setIsAnimating(false);
}
this._animationEnd.next(event);
}
});
}
};
}
/**
* `<mat-drawer-container>` component.
*
* This is the parent component to one or two `<mat-drawer>`s that validates the state internally
* and coordinates the backdrop and content styling.
*/
@Component({
selector: 'mat-drawer-container',
exportAs: 'matDrawerContainer',
templateUrl: 'drawer-container.html',
styleUrl: 'drawer.css',
host: {
'class': 'mat-drawer-container',
'[class.mat-drawer-container-explicit-backdrop]': '_backdropOverride',
},
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
providers: [
{
provide: MAT_DRAWER_CONTAINER,
useExisting: MatDrawerContainer,
},
],
imports: [MatDrawerContent],
})
export class MatDrawerContainer implements AfterContentInit, DoCheck, OnDestroy {
private _dir = inject(Directionality, {optional: true});
private _element = inject<ElementRef<HTMLElement>>(ElementRef);
private _ngZone = inject(NgZone);
private _changeDetectorRef = inject(ChangeDetectorRef);
private _animationMode = inject(ANIMATION_MODULE_TYPE, {optional: true});
_transitionsEnabled = false;
/** All drawers in the container. Includes drawers from inside nested containers. */
@ContentChildren(MatDrawer, {
// We need to use `descendants: true`, because Ivy will no longer match
// indirect descendants if it's left as false.
descendants: true,
})
_allDrawers: QueryList<MatDrawer>;
/** Drawers that belong to this container. */
_drawers = new QueryList<MatDrawer>();
@ContentChild(MatDrawerContent) _content: MatDrawerContent;
@ViewChild(MatDrawerContent) _userContent: MatDrawerContent;
/** The drawer child with the `start` position. */
get start(): MatDrawer | null {
return this._start;
}
/** The drawer child with the `end` position. */
get end(): MatDrawer | null {
return this._end;
}
/**
* Whether to automatically resize the container whenever
* the size of any of its drawers changes.
*
* **Use at your own risk!** Enabling this option can cause layout thrashing by measuring
* the drawers on every change detection cycle. Can be configured globally via the
* `MAT_DRAWER_DEFAULT_AUTOSIZE` token.
*/
@Input()
get autosize(): boolean {
return this._autosize;
}
set autosize(value: BooleanInput) {
this._autosize = coerceBooleanProperty(value);
}
private _autosize = inject(MAT_DRAWER_DEFAULT_AUTOSIZE);
/**
* Whether the drawer container should have a backdrop while one of the sidenavs is open.
* If explicitly set to `true`, the backdrop will be enabled for drawers in the `side`
* mode as well.
*/
@Input()
get hasBackdrop(): boolean {
return this._drawerHasBackdrop(this._start) || this._drawerHasBackdrop(this._end);
}
set hasBackdrop(value: BooleanInput) {
this._backdropOverride = value == null ? null : coerceBooleanProperty(value);
}
_backdropOverride: boolean | null;
/** Event emitted when the drawer backdrop is clicked. */
@Output() readonly backdropClick: EventEmitter<void> = new EventEmitter<void>();
/** The drawer at the start/end position, independent of direction. */
private _start: MatDrawer | null;
private _end: MatDrawer | null;
/**
* The drawer at the left/right. When direction changes, these will change as well.
* They're used as aliases for the above to set the left/right style properly.
* In LTR, _left == _start and _right == _end.
* In RTL, _left == _end and _right == _start.
*/
private _left: MatDrawer | null;
private _right: MatDrawer | null;
/** Emits when the component is destroyed. */
private readonly _destroyed = new Subject<void>();
/** Emits on every ngDoCheck. Used for debouncing reflows. */
private readonly _doCheckSubject = new Subject<void>();
/**
* Margins to be applied to the content. These are used to push / shrink the drawer content when a
* drawer is open. We use margin rather than transform even for push mode because transform breaks
* fixed position elements inside of the transformed element.
*/
_contentMargins: {left: number | null; right: number | null} = {left: null, right: null};
readonly _contentMarginChanges = new Subject<{left: number | null; right: number | null}>();
/** Reference to the CdkScrollable instance that wraps the scrollable content. */
get scrollable(): CdkScrollable {
return this._userContent || this._content;
}
private _injector = inject(Injector);
constructor(...args: unknown[]);
constructor() {
const platform = inject(Platform);
const viewportRuler = inject(ViewportRuler);
// If a `Dir` directive exists up the tree, listen direction changes
// and update the left/right properties to point to the proper start/end.
this._dir?.change.pipe(takeUntil(this._destroyed)).subscribe(() => {
this._validateDrawers();
this.updateContentMargins();
});
// Since the minimum width of the sidenav depends on the viewport width,
// we need to recompute the margins if the viewport changes.
viewportRuler
.change()
.pipe(takeUntil(this._destroyed))
.subscribe(() => this.updateContentMargins());
if (this._animationMode !== 'NoopAnimations' && platform.isBrowser) {
this._ngZone.runOutsideAngular(() => {
// Enable the animations after a delay in order to skip
// the initial transition if a drawer is open by default.
setTimeout(() => {
this._element.nativeElement.classList.add('mat-drawer-transition');
this._transitionsEnabled = true;
}, 200);
});
}
}
ngAfterContentInit() {
this._allDrawers.changes
.pipe(startWith(this._allDrawers), takeUntil(this._destroyed))
.subscribe((drawer: QueryList<MatDrawer>) => {
this._drawers.reset(drawer.filter(item => !item._container || item._container === this));
this._drawers.notifyOnChanges();
});
this._drawers.changes.pipe(startWith(null)).subscribe(() => {
this._validateDrawers();
this._drawers.forEach((drawer: MatDrawer) => {
this._watchDrawerToggle(drawer);
this._watchDrawerPosition(drawer);
this._watchDrawerMode(drawer);
});
if (
!this._drawers.length ||
this._isDrawerOpen(this._start) ||
this._isDrawerOpen(this._end)
) {
this.updateContentMargins();
}
this._changeDetectorRef.markForCheck();
});
// Avoid hitting the NgZone through the debounce timeout.
this._ngZone.runOutsideAngular(() => {
this._doCheckSubject
.pipe(
debounceTime(10), // Arbitrary debounce time, less than a frame at 60fps
takeUntil(this._destroyed),
)
.subscribe(() => this.updateContentMargins());
});
}
ngOnDestroy() {
this._contentMarginChanges.complete();
this._doCheckSubject.complete();
this._drawers.destroy();
this._destroyed.next();
this._destroyed.complete();
}
/** Calls `open` of both start and end drawers */
open(): void {
this._drawers.forEach(drawer => drawer.open());
}
/** Calls `close` of both start and end drawers */
close(): void {
this._drawers.forEach(drawer => drawer.close());
}
/**
* Recalculates and updates the inline styles for the content. Note that this should be used
* sparingly, because it causes a reflow.
*/
updateContentMargins() {
// 1. For drawers in `over` mode, they don't affect the content.
// 2. For drawers in `side` mode they should shrink the content. We do this by adding to the
// left margin (for left drawer) or right margin (for right the drawer).
// 3. For drawers in `push` mode the should shift the content without resizing it. We do this by
// adding to the left or right margin and simultaneously subtracting the same amount of
// margin from the other side.
let left = 0;
let right = 0;
if (this._left && this._left.opened) {
if (this._left.mode == 'side') {
left += this._left._getWidth();
} else if (this._left.mode == 'push') {
const width = this._left._getWidth();
left += width;
right -= width;
}
}
if (this._right && this._right.opened) {
if (this._right.mode == 'side') {
right += this._right._getWidth();
} else if (this._right.mode == 'push') {
const width = this._right._getWidth();
right += width;
left -= width;
}
}
// If either `right` or `left` is zero, don't set a style to the element. This
// allows users to specify a custom size via CSS class in SSR scenarios where the
// measured widths will always be zero. Note that we reset to `null` here, rather
// than below, in order to ensure that the types in the `if` below are consistent.
left = left || null!;
right = right || null!;
if (left !== this._contentMargins.left || right !== this._contentMargins.right) {
this._contentMargins = {left, right};
// Pull back into the NgZone since in some cases we could be outside. We need to be careful
// to do it only when something changed, otherwise we can end up hitting the zone too often.
this._ngZone.run(() => this._contentMarginChanges.next(this._contentMargins));
}
}
ngDoCheck() {
// If users opted into autosizing, do a check every change detection cycle.
if (this._autosize && this._isPushed()) {
// Run outside the NgZone, otherwise the debouncer will throw us into an infinite loop.
this._ngZone.runOutsideAngular(() => this._doCheckSubject.next());
}
}
/**
* Subscribes to drawer events in order to set a class on the main container element when the
* drawer is open and the backdrop is visible. This ensures any overflow on the container element
* is properly hidden.
*/
private _watchDrawerToggle(drawer: MatDrawer): void {
drawer._animationStarted.pipe(takeUntil(this._drawers.changes)).subscribe(() => {
this.updateContentMargins();
this._changeDetectorRef.markForCheck();
});
if (drawer.mode !== 'side') {
drawer.openedChange
.pipe(takeUntil(this._drawers.changes))
.subscribe(() => this._setContainerClass(drawer.opened));
}
}
/**
* Subscribes to drawer onPositionChanged event in order to
* re-validate drawers when the position changes.
*/
private _watchDrawerPosition(drawer: MatDrawer): void {
// NOTE: We need to wait for the microtask queue to be empty before validating,
// since both drawers may be swapping positions at the same time.
drawer.onPositionChanged.pipe(takeUntil(this._drawers.changes)).subscribe(() => {
afterNextRender({read: () => this._validateDrawers()}, {injector: this._injector});
});
}
/** Subscribes to changes in drawer mode so we can run change detection. */
private _watchDrawerMode(drawer: MatDrawer): void {
drawer._modeChanged
.pipe(takeUntil(merge(this._drawers.changes, this._destroyed)))
.subscribe(() => {
this.updateContentMargins();
this._changeDetectorRef.markForCheck();
});
}
/** Toggles the 'mat-drawer-opened' class on the main 'mat-drawer-container' element. */
private _setContainerClass(isAdd: boolean): void {
const classList = this._element.nativeElement.classList;
const className = 'mat-drawer-container-has-open';
if (isAdd) {
classList.add(className);
} else {
classList.remove(className);
}
}
/** Validate the state of the drawer children components. */
private _validateDrawers() {
this._start = this._end = null;