From 9092f151cf0a91ca5ba43aced520c931747c633b Mon Sep 17 00:00:00 2001 From: igauch Date: Thu, 7 Dec 2023 16:28:18 +0800 Subject: [PATCH] refactor: optimize drawer usage, fix known issues (#530) Co-authored-by: tianwenjie Co-authored-by: JounQin --- .changeset/rude-pants-own.md | 8 + src/drawer/component/drawer-ref.ts | 30 -- src/drawer/component/drawer.component.html | 78 ----- src/drawer/component/drawer.component.ts | 304 +++--------------- .../internal/internal.component.html | 65 ++++ .../internal.component.scss} | 25 +- .../component/internal/internal.component.ts | 215 +++++++++++++ src/drawer/drawer-ref.ts | 36 +++ src/drawer/drawer.module.ts | 4 +- src/drawer/drawer.service.ts | 151 ++++++--- .../{component => }/helper-directives.ts | 0 src/drawer/index.ts | 5 +- src/drawer/types.ts | 29 ++ stories/drawer/basic-drawer.component.ts | 11 +- stories/drawer/basic-drawer.stories.ts | 2 + stories/drawer/mask-drawer.stories.ts | 3 +- .../drawer/service-drawer-cpt.component.ts | 8 +- stories/drawer/service-drawer-cpt.stories.ts | 3 +- stories/drawer/service-drawer.component.ts | 10 +- stories/drawer/service-drawer.stories.ts | 3 +- 20 files changed, 533 insertions(+), 457 deletions(-) create mode 100644 .changeset/rude-pants-own.md delete mode 100644 src/drawer/component/drawer-ref.ts delete mode 100644 src/drawer/component/drawer.component.html create mode 100644 src/drawer/component/internal/internal.component.html rename src/drawer/component/{drawer.component.scss => internal/internal.component.scss} (92%) create mode 100644 src/drawer/component/internal/internal.component.ts create mode 100644 src/drawer/drawer-ref.ts rename src/drawer/{component => }/helper-directives.ts (100%) create mode 100644 src/drawer/types.ts diff --git a/.changeset/rude-pants-own.md b/.changeset/rude-pants-own.md new file mode 100644 index 000000000..7e3ef3b5d --- /dev/null +++ b/.changeset/rude-pants-own.md @@ -0,0 +1,8 @@ +--- +'@alauda/ui': minor +--- + +- fix: `close` event will be triggered twice - close #247 +- fix: no transition when use drawer service - close #529 +- refactor: only instantiate when opened +- [BREAKING CHANGE] refactor: return type of `DrawerService#open` is changed from `DrawerComponent` to `DrawerRef` diff --git a/src/drawer/component/drawer-ref.ts b/src/drawer/component/drawer-ref.ts deleted file mode 100644 index ce7f5766c..000000000 --- a/src/drawer/component/drawer-ref.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ComponentType } from '@angular/cdk/portal'; -import { TemplateRef } from '@angular/core'; -import { Observable } from 'rxjs'; - -export enum DrawerSize { - Small = 'small', - Medium = 'medium', - Big = 'big', -} - -export abstract class DrawerRef, R = any> { - abstract afterClosed: Observable; - abstract afterOpen: Observable; - abstract dispose(result?: R): void; - abstract open(): void; - abstract componentInstance: T | null; - - abstract title?: string | TemplateRef; - abstract footer?: string | TemplateRef; - abstract size?: DrawerSize; - abstract offsetY?: string; - abstract visible?: boolean; - abstract hideOnClickOutside?: boolean; - abstract showClose?: boolean; - abstract drawerClass?: string; - abstract mask?: boolean; - abstract maskClosable?: boolean; - abstract width?: number; - abstract divider?: boolean; -} diff --git a/src/drawer/component/drawer.component.html b/src/drawer/component/drawer.component.html deleted file mode 100644 index 98e5420ae..000000000 --- a/src/drawer/component/drawer.component.html +++ /dev/null @@ -1,78 +0,0 @@ - -
-
-
-
-
-
- - {{ title }} - - - - -
- -
- -
- - - - - -
- - -
-
-
-
diff --git a/src/drawer/component/drawer.component.ts b/src/drawer/component/drawer.component.ts index 4dca1d5e5..765b82421 100644 --- a/src/drawer/component/drawer.component.ts +++ b/src/drawer/component/drawer.component.ts @@ -1,97 +1,45 @@ -import { - ComponentType, - Overlay, - OverlayConfig, - OverlayRef, -} from '@angular/cdk/overlay'; -import { - CdkPortalOutlet, - ComponentPortal, - TemplatePortal, - PortalModule, -} from '@angular/cdk/portal'; -import { CdkScrollable } from '@angular/cdk/scrolling'; -import { NgIf, NgClass, NgStyle, NgTemplateOutlet } from '@angular/common'; +import { ComponentType } from '@angular/cdk/overlay'; import { AfterViewInit, ChangeDetectionStrategy, - ChangeDetectorRef, Component, ContentChild, EventEmitter, - InjectionToken, - Injector, Input, OnChanges, - OnDestroy, - OnInit, Output, SimpleChanges, TemplateRef, - Type, - ViewChild, - ViewContainerRef, - ViewEncapsulation, } from '@angular/core'; -import { - Observable, - Subject, - debounceTime, - filter, - fromEvent, - takeUntil, -} from 'rxjs'; - -import { IconComponent } from '../../icon/icon.component'; -import { isTemplateRef } from '../../utils'; +import { first } from 'rxjs'; -import { DrawerRef, DrawerSize } from './drawer-ref'; +import { DrawerRef } from '../drawer-ref'; +import { DrawerService } from '../drawer.service'; import { DrawerContentDirective, DrawerFooterDirective, DrawerHeaderDirective, -} from './helper-directives'; - -export const DATA = new InjectionToken('drawer-data'); - -const DRAWER_OVERLAY_CLASS = 'aui-drawer-overlay'; - -const SIZE_MAPPER = { - [DrawerSize.Small]: 400, - [DrawerSize.Medium]: 600, - [DrawerSize.Big]: 800, -}; +} from '../helper-directives'; +import { DrawerOptions, DrawerSize } from '../types'; @Component({ selector: 'aui-drawer', - templateUrl: './drawer.component.html', - styleUrls: ['./drawer.component.scss'], - encapsulation: ViewEncapsulation.None, + template: '', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [ - NgIf, - NgClass, - NgStyle, - NgTemplateOutlet, - IconComponent, - CdkScrollable, - PortalModule, - ], + providers: [DrawerService], }) export class DrawerComponent< - T = ComponentType, - R = unknown, - D = unknown, - > - extends DrawerRef - implements OnInit, AfterViewInit, OnChanges, OnDestroy + T = unknown, + C extends object = object, + R = unknown, +> implements AfterViewInit, OnChanges, Required> { @Input() - title: string | TemplateRef; + title: string | TemplateRef; @Input() - footer: string | TemplateRef; + footer: string | TemplateRef; @Input() size: DrawerSize = DrawerSize.Medium; @@ -99,10 +47,11 @@ export class DrawerComponent< @Input() offsetY = '0px'; - @Input() visible: boolean; + @Input() + visible: boolean; @Input() - content: TemplateRef | ComponentType; + content: TemplateRef | ComponentType; @Input() hideOnClickOutside = false; @@ -122,230 +71,49 @@ export class DrawerComponent< @Input() divider = true; - private _value = SIZE_MAPPER[DrawerSize.Medium]; @Input() - set width(value: number) { - this._value = value; - } - - get width() { - return this._value; - } - - get drawerClasses(): Record { - return { - 'aui-drawer': true, - hasDivider: this.divider, - ...(this.drawerClass ? { [this.drawerClass]: true } : null), - }; - } - - private readonly afterClosed$ = new Subject(); - - get afterClosed(): Observable { - return this.afterClosed$.asObservable(); - } - - private readonly afterOpen$ = new Subject(); + width: number; - get afterOpen(): Observable { - return this.afterOpen$.asObservable(); - } + @Input() + contentParams: C; @Output() - drawerViewInit = new EventEmitter(); - - @Output() readonly close = new EventEmitter(); - - @ViewChild('drawerTemplate', { static: true }) - drawerTemplate: TemplateRef; - - @ViewChild(CdkPortalOutlet, { static: false }) - bodyPortalOutlet: CdkPortalOutlet; + readonly close = new EventEmitter(); @ContentChild(DrawerHeaderDirective, { read: TemplateRef }) - titleTemplate: TemplateRef; + private readonly titleTemplate: TemplateRef; @ContentChild(DrawerContentDirective, { read: TemplateRef }) - contentTemplate: TemplateRef | ComponentType; + private readonly contentTemplateOrComponent: + | TemplateRef + | ComponentType; @ContentChild(DrawerFooterDirective, { read: TemplateRef }) - footerTemplate: TemplateRef; - - onDestroy$ = new Subject(); - - isTemplateRef = isTemplateRef; - - componentInstance: T | null = null; - - contentParams: D; - overlayRef: OverlayRef; - portal: TemplatePortal; - templateContext = {}; - get transform() { - return `translateX(${this.visible ? 0 : '100%'})`; - } - - constructor( - private readonly viewContainerRef: ViewContainerRef, - private readonly overlay: Overlay, - private readonly injector: Injector, - private readonly cdr: ChangeDetectorRef, - ) { - super(); - } - - ngOnInit() { - this.attachOverlay(); - this.updateBodyOverflow(); - this.templateContext = { $implicit: this.contentParams }; + private readonly footerTemplate: TemplateRef; - if (this.mask) { - // Issues: https://github.com/angular/components/issues/10841 - // scrollStrategy 为 Block 时,若创建 Overlay 时,高度不足以出现滚动,则 scrollStrategy 不会生效 - fromEvent(window, 'resize') - .pipe( - debounceTime(100), - filter( - () => document.documentElement.scrollHeight > window.innerHeight, - ), - takeUntil(this.onDestroy$), - ) - .subscribe(() => { - this.overlayRef.getConfig().scrollStrategy.enable(); - }); - } + private drawerRef: DrawerRef; - this.cdr.detectChanges(); - } + constructor(private readonly drawerService: DrawerService) {} ngOnChanges(changes: SimpleChanges): void { const { visible } = changes; if (visible) { const value = visible.currentValue; if (value) { - this.open(); + this.drawerRef = this.drawerService.open(this); + this.drawerRef.afterClosed.pipe(first()).subscribe(res => { + this.close.emit(res); + }); } else if (!visible.firstChange) { // 不希望默认关闭时,drawer 渲染后就触发 close 事件 - this.dispose(); + this.drawerRef.close(); } } } ngAfterViewInit() { - this.attachBodyContent(); - setTimeout(() => { - this.drawerViewInit.emit(); - }, 0); - } - - private attachOverlay() { - if (!this.overlayRef) { - this.portal = new TemplatePortal( - this.drawerTemplate, - this.viewContainerRef, - ); - this.overlayRef = this.overlay.create(this.getOverlayConfig()); - } - if (this.overlayRef) { - this.overlayRef.attach(this.portal); - this.overlayRef - .outsidePointerEvents() - .pipe(takeUntil(this.onDestroy$)) - .subscribe(event => { - // 判断鼠标点击事件的 target 是否为 overlay-container 的子节点,如果是,则不关闭 drawer。 - // 为了避免点击 drawer 里的 tooltip 后 drawer 被关闭。 - if ( - this.visible && - this.hideOnClickOutside && - event.target instanceof Node && - !this.overlayRef.hostElement?.parentNode?.contains(event.target) - ) { - event.stopPropagation(); - event.preventDefault(); - this.dispose(); - } - }); - } - } - - private getOverlayConfig(): OverlayConfig { - return new OverlayConfig({ - panelClass: DRAWER_OVERLAY_CLASS, - positionStrategy: this.overlay.position().global(), - scrollStrategy: this.mask - ? this.overlay.scrollStrategies.block() - : this.overlay.scrollStrategies.noop(), - }); - } - - private attachBodyContent(): void { - this.bodyPortalOutlet?.dispose(); - const content = this.content || this.contentTemplate; - if (content instanceof Type) { - const componentPortal = new ComponentPortal( - content, - null, - Injector.create({ - providers: [ - { - provide: DATA, - useValue: this.contentParams, - }, - ], - parent: this.injector, - }), - ); - const componentRef = - this.bodyPortalOutlet?.attachComponentPortal(componentPortal); - this.componentInstance = componentRef.instance; - Object.assign(componentRef.instance, this.contentParams); - componentRef.changeDetectorRef.detectChanges(); - } - } - - private updateBodyOverflow(): void { - if (this.overlayRef) { - if (this.visible) { - this.overlayRef.getConfig().scrollStrategy.enable(); - } else { - this.overlayRef.getConfig().scrollStrategy.disable(); - } - } - } - - open() { - this.visible = true; - this.afterOpen$.next(); - this.afterOpen$.complete(); - this.updateBodyOverflow(); - this.cdr.markForCheck(); - } - - dispose(result: R = null) { - this.visible = false; - this.close.emit(); - this.afterClosed$.next(result); - this.afterClosed$.complete(); - this.updateBodyOverflow(); - this.cdr.markForCheck(); - } - - private disposeOverlay(): void { - if (this.overlayRef) { - this.overlayRef.dispose(); - } - this.overlayRef = null; - } - - maskClick() { - if (this.maskClosable && this.mask) { - this.dispose(); - } - } - - ngOnDestroy(): void { - this.onDestroy$.next(); - this.disposeOverlay(); + this.title = this.title || this.titleTemplate; + this.content = this.content || this.contentTemplateOrComponent; + this.footer = this.footer || this.footerTemplate; } } diff --git a/src/drawer/component/internal/internal.component.html b/src/drawer/component/internal/internal.component.html new file mode 100644 index 000000000..008d70265 --- /dev/null +++ b/src/drawer/component/internal/internal.component.html @@ -0,0 +1,65 @@ +
+
+
+
+
+
+ + + {{ options.title }} + +
+ +
+ +
+ + + +
+ + +
+
+
diff --git a/src/drawer/component/drawer.component.scss b/src/drawer/component/internal/internal.component.scss similarity index 92% rename from src/drawer/component/drawer.component.scss rename to src/drawer/component/internal/internal.component.scss index c35ad832a..b58961792 100644 --- a/src/drawer/component/drawer.component.scss +++ b/src/drawer/component/internal/internal.component.scss @@ -1,5 +1,6 @@ -@import '../../theme/var'; -@import '../../theme/mixin'; +@import '../../../theme/var'; +@import '../../../theme/mixin'; +@import '../../../theme/motion'; $drawer: aui-drawer; @@ -16,23 +17,16 @@ $drawer: aui-drawer; } } +@include fade-motion(aui-drawer-mask, aui-fade, 0.3s); + .#{$drawer} { position: fixed; top: 0; bottom: 0; right: 0; z-index: 9999; - transition: transform 0.3s, opacity 0.3s, box-shaow 0.3s; - @include text-set(m, main); - &.isOpen &__content { - @include theme-light { - box-shadow: -2px 0 8px 0 use-rgba(origin-shadow, 0.2); - } - @include theme-dark { - box-shadow: -2px 0 8px 0 use-rgba(origin-shadow, 0.75); - } - } + @include text-set(m, main); &__content { background-color: use-rgb(n-10); @@ -40,6 +34,13 @@ $drawer: aui-drawer; height: 100%; right: 0; width: 100%; + + @include theme-light { + box-shadow: -2px 0 8px 0 use-rgba(origin-shadow, 0.2); + } + @include theme-dark { + box-shadow: -2px 0 8px 0 use-rgba(origin-shadow, 0.75); + } } &__header { diff --git a/src/drawer/component/internal/internal.component.ts b/src/drawer/component/internal/internal.component.ts new file mode 100644 index 000000000..f706d4922 --- /dev/null +++ b/src/drawer/component/internal/internal.component.ts @@ -0,0 +1,215 @@ +import { + animate, + AnimationEvent, + state, + style, + transition, + trigger, +} from '@angular/animations'; +import { + CdkPortalOutlet, + ComponentPortal, + PortalModule, +} from '@angular/cdk/portal'; +import { CdkScrollable } from '@angular/cdk/scrolling'; +import { + AsyncPipe, + NgClass, + NgIf, + NgStyle, + NgTemplateOutlet, +} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + InjectionToken, + Injector, + Type, + ViewChild, + ViewEncapsulation, +} from '@angular/core'; +import { BehaviorSubject, Subject } from 'rxjs'; + +import { TimingFunction } from '../../../core/animation/animation-consts'; +import { IconComponent } from '../../../icon/icon.component'; +import { handlePixel, isTemplateRef } from '../../../utils'; +import { DrawerOptions, DrawerSize } from '../../types'; + +export const DATA = new InjectionToken('drawer-data'); + +const SIZE_MAPPER = { + [DrawerSize.Small]: 400, + [DrawerSize.Medium]: 600, + [DrawerSize.Big]: 800, +}; +const DRAWER_OVERLAY_BACKDROP_CLASS = 'aui-drawer-mask'; + +export const duration = '300ms'; + +type Step = 'showStart' | 'showDone' | 'hideStart' | 'hideDone'; + +@Component({ + templateUrl: './internal.component.html', + styleUrls: ['./internal.component.scss'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + NgIf, + NgClass, + NgStyle, + NgTemplateOutlet, + IconComponent, + CdkScrollable, + PortalModule, + AsyncPipe, + ], + animations: [ + trigger('showHide', [ + state( + 'show', + style({ + opacity: 1, + transform: 'translateX(0)', + }), + ), + state( + 'hide, void', + style({ + opacity: 0, + transform: 'translateX(100%)', + }), + ), + transition('hide => show, void => show', [ + animate(`${duration} ${TimingFunction.easeOut}`), + ]), + transition('show => hide, show => void', [ + animate(`${duration} ${TimingFunction.easeInOut}`), + ]), + ]), + ], +}) +export class DrawerInternalComponent { + @ViewChild(CdkPortalOutlet, { static: false }) + bodyPortalOutlet: CdkPortalOutlet; + + @ViewChild('mask') + mask: ElementRef; + + animationStep$ = new Subject(); + + options: DrawerOptions; + showHide$$ = new BehaviorSubject<'show' | 'hide'>('hide'); + maskVisible$ = new Subject(); + + get drawerClasses(): Record { + return { + 'aui-drawer': true, + hasDivider: this.options.divider, + ...(this.options.drawerClass + ? { [this.options.drawerClass]: true } + : null), + }; + } + + get width() { + return handlePixel( + this.options.width || SIZE_MAPPER[this.options.size || DrawerSize.Medium], + ); + } + + isTemplateRef = isTemplateRef; + + constructor(private readonly injector: Injector) {} + + ngAfterViewInit() { + this.attachBodyContent(); + } + + private attachBodyContent(): void { + this.bodyPortalOutlet?.dispose(); + const content = this.options.content; + if (!(content instanceof Type)) { + return; + } + const componentPortal = new ComponentPortal( + content, + null, + Injector.create({ + providers: [ + { + provide: DATA, + useValue: this.options.contentParams, + }, + ], + parent: this.injector, + }), + ); + const componentRef = + this.bodyPortalOutlet?.attachComponentPortal(componentPortal); + Object.assign(componentRef.instance, this.options.contentParams); + componentRef.changeDetectorRef.detectChanges(); + } + + onAnimation(event: AnimationEvent) { + const { phaseName, toState } = event; + if (!['show', 'hide'].includes(toState)) { + return; + } + + const step = [ + toState, + phaseName.charAt(0).toUpperCase() + phaseName.slice(1), + ].join('') as Step; + this.animationStep$.next(step); + + const backdropElement = this.mask?.nativeElement; + if (!backdropElement) { + return; + } + + const enters = [ + `${DRAWER_OVERLAY_BACKDROP_CLASS}-enter`, + `${DRAWER_OVERLAY_BACKDROP_CLASS}-enter-active`, + ]; + const leaves = [ + `${DRAWER_OVERLAY_BACKDROP_CLASS}-leave`, + `${DRAWER_OVERLAY_BACKDROP_CLASS}-leave-active`, + ]; + switch (step) { + case 'showStart': { + backdropElement.classList.add(...enters); + this.maskVisible$.next(true); + break; + } + case 'hideStart': { + backdropElement.classList.add(...leaves); + break; + } + case 'showDone': { + backdropElement.classList.remove(...enters); + break; + } + case 'hideDone': { + this.maskVisible$.next(false); + backdropElement.classList.remove(...leaves); + break; + } + } + } + + show() { + this.showHide$$.next('show'); + } + + hide() { + this.showHide$$.next('hide'); + } + + maskClick() { + if (this.options.maskClosable) { + this.hide(); + } + } +} diff --git a/src/drawer/drawer-ref.ts b/src/drawer/drawer-ref.ts new file mode 100644 index 000000000..ad53055cb --- /dev/null +++ b/src/drawer/drawer-ref.ts @@ -0,0 +1,36 @@ +import { filter, Observable, Subject } from 'rxjs'; + +import { DrawerInternalComponent } from './component/internal/internal.component'; + +export class DrawerRef { + private result: R; + + private readonly afterOpen$ = new Subject(); + private readonly afterClosed$ = new Subject(); + + get afterOpen(): Observable { + return this.afterOpen$.asObservable(); + } + + get afterClosed(): Observable { + return this.afterClosed$.asObservable(); + } + + constructor(public drawerInstance: DrawerInternalComponent) { + this.drawerInstance.animationStep$ + .pipe(filter(step => step === 'hideDone')) + .subscribe(() => { + this.afterClosed$.next(this.result); + this.afterClosed$.complete(); + }); + } + + open() { + this.drawerInstance.show(); + } + + close(result?: R): void { + this.result = result; + this.drawerInstance.hide(); + } +} diff --git a/src/drawer/drawer.module.ts b/src/drawer/drawer.module.ts index 5166ce551..4b1e8801c 100644 --- a/src/drawer/drawer.module.ts +++ b/src/drawer/drawer.module.ts @@ -6,12 +6,12 @@ import { NgModule } from '@angular/core'; import { IconModule } from '../icon'; import { DrawerComponent } from './component/drawer.component'; +import { DrawerService } from './drawer.service'; import { DrawerContentDirective, DrawerFooterDirective, DrawerHeaderDirective, -} from './component/helper-directives'; -import { DrawerService } from './drawer.service'; +} from './helper-directives'; const COMMON = [ DrawerComponent, diff --git a/src/drawer/drawer.service.ts b/src/drawer/drawer.service.ts index 780b0c09f..fcad4da05 100644 --- a/src/drawer/drawer.service.ts +++ b/src/drawer/drawer.service.ts @@ -1,65 +1,120 @@ -import { Overlay, OverlayRef } from '@angular/cdk/overlay'; -import { ComponentPortal, ComponentType } from '@angular/cdk/portal'; -import { ComponentRef, Injectable, TemplateRef } from '@angular/core'; -import { Subject, takeUntil } from 'rxjs'; +import { Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay'; +import { ComponentPortal } from '@angular/cdk/portal'; +import { ComponentRef, Injectable } from '@angular/core'; +import { debounceTime, filter, fromEvent, Subject, takeUntil } from 'rxjs'; -import { DrawerComponent } from './component/drawer.component'; +import { DrawerInternalComponent } from './component/internal/internal.component'; +import { DrawerRef } from './drawer-ref'; +import { DrawerOptions } from './types'; -import { DrawerSize } from '.'; +const DRAWER_OVERLAY_CLASS = 'aui-drawer-overlay'; -export interface DrawerOptions { - title?: string | TemplateRef; - width?: number; - content?: ComponentType | TemplateRef; - contentParams?: D; - footer?: string | TemplateRef; - offsetY?: string; - divider?: boolean; - drawerClass?: string; - size?: DrawerSize; - visible?: boolean; - hideOnClickOutside?: boolean; - showClose?: boolean; - mask?: boolean; - maskClosable?: boolean; -} @Injectable() -export class DrawerService { - private drawerRef: ComponentRef; +export class DrawerService< + T = unknown, + C extends object = object, + R = unknown, +> { private overlayRef: OverlayRef; - private readonly unsubscribe$ = new Subject(); + options: DrawerOptions; + drawerRef: DrawerRef; + invisible$ = new Subject(); + private drawerInternalComponentRef: ComponentRef< + DrawerInternalComponent + >; constructor(private readonly overlay: Overlay) {} - open(options: DrawerOptions) { - this.drawerRef?.instance?.dispose(); - this.createDrawer(); + open(options: DrawerOptions) { this.updateOptions(options); - return this.drawerRef?.instance; + this.createOverlay(); + this.createDrawer(); + this.drawerRef = new DrawerRef( + this.drawerInternalComponentRef.instance, + ); + this.drawerRef.open(); + + return this.drawerRef; } - updateOptions(options: DrawerOptions): void { - Object.assign(this.drawerRef.instance, options); + updateOptions(options: DrawerOptions): void { + this.options = options; } - private createDrawer(): void { - this.overlayRef = this.overlay.create(); - this.drawerRef = this.overlayRef.attach( - new ComponentPortal(DrawerComponent), - ); - this.drawerRef.instance.drawerViewInit - .pipe(takeUntil(this.unsubscribe$)) - .subscribe(() => { - this.drawerRef.instance.open(); - }); + private createOverlay() { + if (!this.overlayRef) { + this.overlayRef = this.overlay.create(this.getOverlayConfig()); + } - this.drawerRef.instance.afterClosed - .pipe(takeUntil(this.unsubscribe$)) - .subscribe(() => { - this.overlayRef.dispose(); - this.drawerRef = null; - this.unsubscribe$.next(); - this.unsubscribe$.complete(); + this.overlayRef + .outsidePointerEvents() + .pipe(takeUntil(this.invisible$)) + .subscribe(event => { + // 判断鼠标点击事件的 target 是否为 overlay-container 的子节点,如果是,则不关闭 drawer。 + // 为了避免点击 drawer 里的 tooltip 后 drawer 被关闭。 + if ( + this.overlayRef && + this.options.hideOnClickOutside && + event.target instanceof Node && + !this.overlayRef.hostElement?.parentNode?.contains(event.target) + ) { + event.stopPropagation(); + event.preventDefault(); + this.drawerRef.close(); + } }); + + this.overlayRef.getConfig().scrollStrategy.enable(); + if (this.options.mask) { + // Issues: https://github.com/angular/components/issues/10841 + // scrollStrategy 为 Block 时,若创建 Overlay 时,高度不足以出现滚动,则 scrollStrategy 不会生效 + fromEvent(window, 'resize') + .pipe( + debounceTime(100), + filter( + () => document.documentElement.scrollHeight > window.innerHeight, + ), + takeUntil(this.invisible$), + ) + .subscribe(() => { + this.overlayRef.getConfig().scrollStrategy.enable(); + }); + } + } + + private createDrawer() { + if (this.drawerInternalComponentRef) { + return; + } + const drawerInternalComponentRef = this.overlayRef.attach( + new ComponentPortal(DrawerInternalComponent), + ); + drawerInternalComponentRef.instance.options = this.options; + drawerInternalComponentRef.instance.animationStep$.subscribe(step => { + if (step === 'hideDone') { + this.invisible$.next(); + this.overlayRef?.getConfig().scrollStrategy.disable(); + } + }); + this.drawerInternalComponentRef = drawerInternalComponentRef; + } + + private getOverlayConfig(): OverlayConfig { + return new OverlayConfig({ + panelClass: DRAWER_OVERLAY_CLASS, + positionStrategy: this.overlay.position().global(), + scrollStrategy: this.options.mask + ? this.overlay.scrollStrategies.block() + : this.overlay.scrollStrategies.noop(), + }); + } + + ngOnDestroy(): void { + this.invisible$.next(); + if (this.overlayRef) { + this.overlayRef.getConfig().scrollStrategy.disable(); + this.overlayRef.dispose(); + this.overlayRef = null; + } } } diff --git a/src/drawer/component/helper-directives.ts b/src/drawer/helper-directives.ts similarity index 100% rename from src/drawer/component/helper-directives.ts rename to src/drawer/helper-directives.ts diff --git a/src/drawer/index.ts b/src/drawer/index.ts index 5cbffa988..b1d0a82ed 100644 --- a/src/drawer/index.ts +++ b/src/drawer/index.ts @@ -1,5 +1,6 @@ export * from './component/drawer.component'; -export * from './component/drawer-ref'; -export * from './component/helper-directives'; export * from './drawer.module'; export * from './drawer.service'; +export * from './drawer-ref'; +export * from './helper-directives'; +export * from './types'; diff --git a/src/drawer/types.ts b/src/drawer/types.ts new file mode 100644 index 000000000..be58712aa --- /dev/null +++ b/src/drawer/types.ts @@ -0,0 +1,29 @@ +import { ComponentType } from '@angular/cdk/portal'; +import { TemplateRef } from '@angular/core'; + +import { ValueOf } from '../types'; + +export const DrawerSize = { + Small: 'small', + Medium: 'medium', + Big: 'big', +} as const; + +export type DrawerSize = ValueOf; + +export interface DrawerOptions { + title?: string | TemplateRef; + content?: ComponentType | TemplateRef; + footer?: string | TemplateRef; + contentParams?: C; // 不仅作为content的参数,同时是title和footer的上下文 + width?: number; + size?: DrawerSize; // 内置的宽度尺寸,也可以使用 width 自定义 + offsetY?: string; + divider?: boolean; + drawerClass?: string; + visible?: boolean; + showClose?: boolean; + mask?: boolean; + maskClosable?: boolean; // 点击背景是否关闭抽屉 + hideOnClickOutside?: boolean; // 在抽屉外点击是否关闭抽屉,与 maskClosable 的区别是是否有 mask +} diff --git a/stories/drawer/basic-drawer.component.ts b/stories/drawer/basic-drawer.component.ts index 8f4a1a01b..2501111ec 100644 --- a/stories/drawer/basic-drawer.component.ts +++ b/stories/drawer/basic-drawer.component.ts @@ -29,10 +29,13 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; [divider]="divider" [offsetY]="offsetY + 'px'" [visible]="visible" - (close)="close()" + [hideOnClickOutside]="true" + (close)="closeHandle()" >
header
- content + + +
footer
`, @@ -49,4 +52,8 @@ export class BasicDrawerComponent { close() { this.visible = false; } + + closeHandle() { + this.visible = false; + } } diff --git a/stories/drawer/basic-drawer.stories.ts b/stories/drawer/basic-drawer.stories.ts index 04d2528f7..c5fc884e6 100644 --- a/stories/drawer/basic-drawer.stories.ts +++ b/stories/drawer/basic-drawer.stories.ts @@ -1,4 +1,5 @@ import { FormsModule } from '@angular/forms'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { Meta, StoryObj, moduleMetadata } from '@storybook/angular'; import { BasicDrawerComponent } from './basic-drawer.component'; @@ -22,6 +23,7 @@ const meta: Meta = { InputModule, FormsModule, SwitchModule, + BrowserAnimationsModule, ], }), ], diff --git a/stories/drawer/mask-drawer.stories.ts b/stories/drawer/mask-drawer.stories.ts index 73fd798f8..578d66a1c 100644 --- a/stories/drawer/mask-drawer.stories.ts +++ b/stories/drawer/mask-drawer.stories.ts @@ -1,3 +1,4 @@ +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { Meta, StoryObj, moduleMetadata } from '@storybook/angular'; import { MaskDrawerComponent } from './mask-drawer.component'; @@ -10,7 +11,7 @@ const meta: Meta = { decorators: [ moduleMetadata({ declarations: [MaskDrawerComponent], - imports: [ButtonModule, DrawerModule], + imports: [ButtonModule, DrawerModule, BrowserAnimationsModule], }), ], }; diff --git a/stories/drawer/service-drawer-cpt.component.ts b/stories/drawer/service-drawer-cpt.component.ts index 67b4e1e72..b3f06e1cd 100644 --- a/stories/drawer/service-drawer-cpt.component.ts +++ b/stories/drawer/service-drawer-cpt.component.ts @@ -37,11 +37,11 @@ export class ServiceDrawerCptComponent { contentParams: { data: 111 }, footer: 'footer', }); - this.drawerRef.afterClosed.subscribe(res => { - console.log(res); + this.drawerRef.afterClosed.subscribe(() => { + // }); this.drawerRef.afterOpen.subscribe(() => { - console.log('open'); + // }); } @@ -56,7 +56,7 @@ export class ServiceDrawerCptComponent { } close() { - this.drawerRef.dispose('on close'); + this.drawerRef.close(); } } diff --git a/stories/drawer/service-drawer-cpt.stories.ts b/stories/drawer/service-drawer-cpt.stories.ts index d7c881f4d..7d8157468 100644 --- a/stories/drawer/service-drawer-cpt.stories.ts +++ b/stories/drawer/service-drawer-cpt.stories.ts @@ -1,3 +1,4 @@ +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { Meta, StoryObj, moduleMetadata } from '@storybook/angular'; import { @@ -13,7 +14,7 @@ const meta: Meta = { component: ServiceDrawerCptComponent, decorators: [ moduleMetadata({ - imports: [ButtonModule, DrawerModule], + imports: [ButtonModule, DrawerModule, BrowserAnimationsModule], declarations: [ ServiceDrawerCptComponent, DrawerContentComponent, diff --git a/stories/drawer/service-drawer.component.ts b/stories/drawer/service-drawer.component.ts index 512fe90d7..a5ae70e53 100644 --- a/stories/drawer/service-drawer.component.ts +++ b/stories/drawer/service-drawer.component.ts @@ -52,21 +52,15 @@ export class ServiceDrawerComponent { drawerRef: DrawerRef; constructor(private readonly drawerService: DrawerService) {} - open(template: TemplateRef) { + open(template: TemplateRef) { this.drawerRef = this.drawerService.open({ title: 'title', content: template, footer: 'footer', }); - this.drawerRef.afterClosed.subscribe(res => { - console.log(res); - }); - this.drawerRef.afterOpen.subscribe(() => { - console.log('open'); - }); } close() { - this.drawerRef.dispose('on close'); + this.drawerRef.close(); } } diff --git a/stories/drawer/service-drawer.stories.ts b/stories/drawer/service-drawer.stories.ts index 72945e470..44e0dac6c 100644 --- a/stories/drawer/service-drawer.stories.ts +++ b/stories/drawer/service-drawer.stories.ts @@ -1,3 +1,4 @@ +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { Meta, StoryObj, moduleMetadata } from '@storybook/angular'; import { ServiceDrawerComponent } from './service-drawer.component'; @@ -10,7 +11,7 @@ const meta: Meta = { decorators: [ moduleMetadata({ declarations: [ServiceDrawerComponent], - imports: [ButtonModule, DrawerModule], + imports: [ButtonModule, DrawerModule, BrowserAnimationsModule], }), ], };