自定义popover
需求背景
项目基于ng-zorro-antd框架实现。实现卡片结合popover组件交互效果。点击一个popover组件的“...”按钮,关闭其他popover组件,打开当前的popover组件。如下图:
ng-zorro-antd提供的popover组件是基于angular/cdk的overlay组件构建的。而overlay组件是有背景层,交互方式是打开一个弹出层,要先关闭才能在打开另一个overlay组件。所以ng-zorro-antd的popover组件交互也是如此。
方案演进
1、静态页面
card-list.component.html:
<div nz-row [nzGutter]="8">
<div nz-col [nzSpan]="8" *ngFor="let item of cardlist.items">
<nz-card nzTitle="{{item.roleName}}" style="margin-bottom:10px;" [nzExtra]="extraTemplate" (click)="openDetails(item)">
<p>{{item.description}}</p>
<p>{{item.createAt}}</p>
</nz-card>
</div>
</div>
<ng-template #extraTemplate>
<span
(click)="tabPopover($event)" style="padding:5px;">
<i class="anticon anticon-ellipsis"></i>
</span>
<div class="popover bottom" (click)="stopP($event)">
<div class="arrow"></div>
<h3 class="popover-title" *ngIf="title != null">{{title}}
</h3>
<div class="popover-content">
<ul class="popover-itemsBox">
<li class="popover-item">
<a routerLink="../roleDetails" class="popover-item-link">
<i class="anticon anticon-edit"></i>编辑
</a>
</li>
<li class="popover-item">
<a (click)="showDeleteConfirm()" class="popover-item-link">
<i class="anticon anticon-delete"></i>删除
</a>
</li>
</ul>
</div>
</div>
</ng-template>
card-list.component.css:
.popover {
position: absolute;
top: 36px;
right: -3px;
z-index: 1060;
display: none;
max- 276px;
padding: 1px;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-style: normal;
font-weight: normal;
letter-spacing: normal;
line-break: auto;
line-height: 1.42857143;
text-align: left;
text-align: start;
text-decoration: none;
text-shadow: none;
text-transform: none;
white-space: normal;
word-break: normal;
word-spacing: normal;
word-wrap: normal;
font-size: 14px;
background-color: #ffffff;
-webkit-background-clip: padding-box;
background-clip: padding-box;
border-radius: 4px;
-webkit-box-shadow: 0 2px 8px rgba(0,0,0,.15);
box-shadow: 0 2px 8px rgba(0,0,0,.15);
}
.popover.bottom {
margin-top: 10px;
}
.popover-title {
margin: 0;
padding: 8px 14px;
font-size: 14px;
border-bottom: 1px solid #e8e8e8;
}
.popover-content {
padding: 9px 14px;
}
.popover > .arrow,
.popover > .arrow:after {
position: absolute;
display: block;
0;
height: 0;
border-color: transparent;
border-style: solid;
}
.popover > .arrow {
border- 11px;
}
.popover > .arrow:after {
border- 10px;
content: "";
}
.popover.bottom > .arrow {
left: 50%;
margin-left: -11px;
border-top- 0;
border-bottom-color: rgba(0,0,0,.07);
top: -11px;
}
.popover.bottom > .arrow:after {
content: " ";
top: 1px;
margin-left: -10px;
border-top- 0;
border-bottom-color: #ffffff;
}
.clearfix:before,
.clearfix:after {
content: " ";
display: table;
}
.clearfix:after {
clear: both;
}
.pull-right {
float: right !important;
}
.pull-left {
float: left !important;
}
.hide {
display: none !important;
}
.show {
display: block !important;
}
.popover-itemsBox {
list-style: none;
margin: 0;
padding: 0;
}
.popover-item {
color:#888;
padding: 5px 0px;
}
.popover-item-link {
color:#888;
}
.popover-item-link:hover {
color:#1890ff;
}
.popover-item-link .anticon{
margin-right:5px;
}
card-list.component.ts:
import { Component, Renderer2, ElementRef, OnInit, OnDestroy } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { NzModalService } from 'ng-zorro-antd';
class Point {
constructor(public x: number, public y: number) {}
}
@Component({
selector: 'app-card-list',
templateUrl: './card-list.component.html',
styleUrls: ['./card-list.component.css']
})
export class CardListComponent implements OnInit, OnDestroy {
cardlist;
unlistenGlobal;
constructor(
private renderer: Renderer2,
private elRef: ElementRef,
public router: Router,
public activatedRoute: ActivatedRoute,
private modalService: NzModalService
) { }
hasClass(el, cls) {
let clsArr = [], isHasClass = false;
clsArr = el.className.split(/s+/);
for (let i = 0; i < clsArr.length; i++) {
if (clsArr[i] === cls) {
isHasClass = true;
break;
}
}
return isHasClass;
}
closePopover() {
const element = this.elRef.nativeElement.querySelectorAll('.popover');
for (let i = 0; i < element.length; i++) {
this.renderer.removeClass(element[i], 'show');
}
}
stopP($event) {
$event.stopPropagation();
}
tabPopover($event) {
$event.stopPropagation();
const popTarget = this.renderer.parentNode($event.currentTarget).querySelector('.popover');
if (this.hasClass(popTarget, 'show')) {
this.closePopover();
} else {
this.closePopover();
this.renderer.addClass(popTarget, 'show');
}
}
openDetails(item) {
this.router.navigate(['../roleDetails'], { relativeTo: this.activatedRoute });
}
showDeleteConfirm(): void {
this.closePopover();
this.modalService.confirm({
nzTitle: 'Are you sure delete this task?',
nzContent: '<b style="color: red;">Some descriptions</b>',
nzOkText: 'Yes',
nzOkType: 'danger',
nzOnOk: () => console.log('OK'),
nzCancelText: 'No',
nzOnCancel: () => console.log('Cancel')
});
}
ngOnInit() {
this.unlistenGlobal = this.renderer.listen('document', 'click', (evt) => {
this.closePopover();
});
this.cardlist = {
"items": [
{
"createAt": "2018-03-28 11:35:15",
"description": "拥有平台全部功能的权限",
"id": 4,
"isShare": 1,
"roleName": "系统管理员",
"tenantID": null,
"updateAt": null
},
{
"createAt": "2018-03-28 11:35:15",
"description": "能管理设备、产品、分组等信息",
"id": 5,
"isShare": 1,
"roleName": "设备管理员",
"tenantID": null,
"updateAt": null
},
{
"createAt": "2018-03-28 11:35:15",
"description": "仅能查看所有资源信息",
"id": 6,
"isShare": 1,
"roleName": "普通用户",
"tenantID": null,
"updateAt": null
},
{
"createAt": "2018-08-28 20:08:33",
"description": null,
"id": 23,
"isShare": 0,
"roleName": "分组用户",
"tenantID": "Cb81t4UWN",
"updateAt": null
},
{
"createAt": "2018-08-29 13:07:45",
"description": null,
"id": 24,
"isShare": 0,
"roleName": "产品经理",
"tenantID": "Cb81t4UWN",
"updateAt": null
},
{
"createAt": "2018-09-03 15:32:12",
"description": "系统管理员",
"id": 25,
"isShare": 0,
"roleName": "admin",
"tenantID": "Cb81t4UWN",
"updateAt": null
},
{
"createAt": "2018-09-05 09:16:03",
"description": "test",
"id": 27,
"isShare": 0,
"roleName": "melin",
"tenantID": "Cb81t4UWN",
"updateAt": null
},
{
"createAt": "2018-09-06 09:00:42",
"description": "test",
"id": 28,
"isShare": 0,
"roleName": "melin02",
"tenantID": "Cb81t4UWN",
"updateAt": null
},
{
"createAt": "2018-09-25 13:46:54",
"description": "test",
"id": 29,
"isShare": 0,
"roleName": "设备管理员",
"tenantID": "Cb81t4UWN",
"updateAt": null
}
],
"meta": {
"count": 9,
"limit": 10,
"page": 1
}
};
}
ngOnDestroy(): void {
this.unlistenGlobal();
}
}
2、可复用-组件化
card.component.html:
<nz-card nzTitle="{{popoverData.roleName}}" style="margin-bottom:10px;" [nzExtra]="extraTemplate">
<ng-content></ng-content>
</nz-card>
<ng-template #extraTemplate >
<span
(click)="tabPopover($event)" style="padding:5px;">
<i class="anticon anticon-ellipsis"></i>
</span>
<div class="popover bottom " (click)="stopP($event)">
<div class="arrow"></div>
<h3 class="popover-title" *ngIf="title != null">{{title}}</h3>
<div class="popover-content">
<ul class="popover-itemsBox">
<li class="popover-item"><a routerLink="../roleDetails" class="popover-item-link"><i class="anticon anticon-edit"></i>编辑</a></li>
<li class="popover-item"><a (click)="showDeleteConfirm()" class="popover-item-link"><i class="anticon anticon-delete"></i>删除</a></li>
</ul>
</div>
</div>
</ng-template>
card.component.css:
.popover {
position: absolute;
top: 36px;
right: -3px;
z-index: 1060;
display: none;
max- 276px;
padding: 1px;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-style: normal;
font-weight: normal;
letter-spacing: normal;
line-break: auto;
line-height: 1.42857143;
text-align: left;
text-align: start;
text-decoration: none;
text-shadow: none;
text-transform: none;
white-space: normal;
word-break: normal;
word-spacing: normal;
word-wrap: normal;
font-size: 14px;
background-color: #ffffff;
-webkit-background-clip: padding-box;
background-clip: padding-box;
border-radius: 4px;
-webkit-box-shadow: 0 2px 8px rgba(0,0,0,.15);
box-shadow: 0 2px 8px rgba(0,0,0,.15);
}
.popover.bottom {
margin-top: 10px;
}
.popover-title {
margin: 0;
padding: 8px 14px;
font-size: 14px;
border-bottom: 1px solid #e8e8e8;
}
.popover-content {
padding: 9px 14px;
}
.popover > .arrow,
.popover > .arrow:after {
position: absolute;
display: block;
0;
height: 0;
border-color: transparent;
border-style: solid;
}
.popover > .arrow {
border- 11px;
}
.popover > .arrow:after {
border- 10px;
content: "";
}
.popover.bottom > .arrow {
left: 50%;
margin-left: -11px;
border-top- 0;
border-bottom-color: rgba(0,0,0,.07);
top: -11px;
}
.popover.bottom > .arrow:after {
content: " ";
top: 1px;
margin-left: -10px;
border-top- 0;
border-bottom-color: #ffffff;
}
.clearfix:before,
.clearfix:after {
content: " ";
display: table;
}
.clearfix:after {
clear: both;
}
.pull-right {
float: right !important;
}
.pull-left {
float: left !important;
}
.hide {
display: none !important;
}
.show {
display: block !important;
}
.popover-itemsBox {
list-style: none;
margin: 0;
padding: 0;
}
.popover-item {
color:#888;
padding: 5px 0px;
}
.popover-item-link {
color:#888;
}
.popover-item-link:hover {
color:#1890ff;
}
.popover-item-link .anticon{
margin-right:5px;
}
card.component.ts:
import { Component, OnInit, Input, Renderer2, ElementRef, OnDestroy, ViewContainerRef, Inject } from '@angular/core';
import { DOCUMENT } from '@angular/platform-browser';
@Component({
selector: 'app-popover',
templateUrl: './popover.component.html',
styleUrls: ['./popover.component.css']
})
export class PopoverComponent implements OnInit, OnDestroy {
@Input() popoverData;
unlistenGlobal;
constructor(
private renderer: Renderer2,
@Inject(DOCUMENT) private _document: any
) { }
ngOnInit() {
this.unlistenGlobal = this.renderer.listen('document', 'click', (evt) => {
this.closePopover();
});
}
ngOnDestroy(): void {
this.unlistenGlobal();
}
hasClass(el, cls) {
let clsArr = [], isHasClass = false;
clsArr = el.className.split(/s+/);
for (let i = 0; i < clsArr.length; i++) {
if (clsArr[i] === cls) {
isHasClass = true;
break;
}
}
return isHasClass;
}
closePopover() {
const element = this._document.querySelectorAll('.popover');
if (element.length > 0) {
for (let i = 0; i < element.length; i++) {
this.renderer.removeClass(element[i], 'show');
}
}
}
stopP($event) {
$event.stopPropagation();
}
tabPopover($event) {
$event.stopPropagation();
const popTarget = this.renderer.parentNode($event.currentTarget).querySelector('.popover');
if (this.hasClass(popTarget, 'show')) {
this.closePopover();
} else {
this.closePopover();
this.renderer.addClass(popTarget, 'show');
}
}
showDeleteConfirm(): void {
this.closePopover();
}
}
其他组件使用card组件(要在module注入card组件):
<div nz-row [nzGutter]="8">
<div nz-col [nzSpan]="8" *ngFor="let item of cardlist.items">
<app-popover style="margin-bottom:10px;" [popoverData]="item">
<p>{{item.description}}</p>
<p>{{item.createAt}}</p>
</app-popover>
</div>
</div>
3、封装成自定义指令
Popover文件结构:
具体代码实现如下:
popover.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SsPopover } from './popover.directive';
import { PopoverComponent } from './popover.component';
@NgModule({
declarations: [SsPopover, PopoverComponent],
exports: [SsPopover],
imports: [CommonModule],
entryComponents: [PopoverComponent]
})
export class PopoverModule { }
popover.directive.ts
import { Directive, Input, TemplateRef, ViewContainerRef, Output, EventEmitter, OnInit, OnDestroy, HostListener, ElementRef, ComponentRef, ComponentFactoryResolver, Renderer2, Inject, ViewRef, Injector } from '@angular/core';
import { PopoverComponent } from './popover.component';
import { DOCUMENT } from '@angular/platform-browser';
export class ContentRef {
constructor(public nodes: any[], public viewRef?: ViewRef, public componentRef?: ComponentRef<any>) { }
}
class Point {
constructor(public x: number, public y: number) { }
}
@Directive({
// tslint:disable-next-line:directive-selector
selector: '[ssPopover]',
exportAs: 'ssPopover'
})
// tslint:disable-next-line:directive-class-suffix
export class SsPopover implements OnInit, OnDestroy {
private element: HTMLElement;
private _mypopoverRef: ComponentRef<PopoverComponent>;
private unlistenGlobal;
private unlistenPopover;
private _contentRef: ContentRef;
constructor(
private render: Renderer2,
private elementRef: ElementRef,
private viewContainer: ViewContainerRef,
private _injector: Injector,
private componentFactoryResolver: ComponentFactoryResolver,
@Inject(DOCUMENT) private _document: any
) { }
/**
* Content to be displayed as popover. If title and content are empty, the popover won't open.
*/
@Input() ssPopover: string | TemplateRef<any>;
/**
* Title of a popover. If title and content are empty, the popover won't open.
*/
@Input() popoverTitle: string | TemplateRef<any>;
@HostListener('click', ['$event'])
onClick(event: any) {
event.stopPropagation();
const btnElement = this._document.querySelectorAll('.ss-popover-btn');
if (btnElement.length > 0) {
if (this.hasClass(this.element, 'ss-popover-btn')) {
this.removePopover();
} else {
this.removePopover();
this.showPopover();
}
} else {
this.showPopover();
}
}
hasClass(el, cls) {
let clsArr = [], isHasClass = false;
clsArr = el.className.split(/s+/);
for (let i = 0; i < clsArr.length; i++) {
if (clsArr[i] === cls) {
isHasClass = true;
break;
}
}
return isHasClass;
}
showPopover() {
this._mypopoverRef = this.createPopover(this.popoverTitle);
const popoverEl = this._mypopoverRef.location.nativeElement.querySelector('.popover');
const popoverContentEl = this._mypopoverRef.location.nativeElement.querySelector('.popover-content');
const targetPos = this.getTargetLocation();
this.render.setStyle(popoverEl, 'right', targetPos.x + 'px');
this.render.setStyle(popoverEl, 'top', targetPos.y + 'px');
this.render.addClass(popoverEl, 'show');
this.render.addClass(this.element, 'ss-popover-btn');
this.render.appendChild(this.render.parentNode(this.element), this._mypopoverRef.location.nativeElement);
this.unlistenPopover = this.render.listen(popoverContentEl, 'click', (evt) => {
this.removePopover();
});
}
hidePopover() {
const popoverEl = this._mypopoverRef.location.nativeElement.querySelector('.popover');
if (this._mypopoverRef) {
this.viewContainer.remove(this.viewContainer.indexOf(this._mypopoverRef.hostView));
this.render.removeClass(popoverEl, 'show');
this._mypopoverRef = null;
}
}
removePopover() {
const btnElement = this._document.querySelectorAll('.ss-popover-btn');
if (btnElement.length > 0) {
for (let i = 0; i < btnElement.length; i++) {
this.render.removeClass(btnElement[i], 'ss-popover-btn');
this.render.removeChild(this.render.parentNode(btnElement[i]), this.render.parentNode(btnElement[i]).querySelector('.popover'));
}
}
}
private createPopover(title): ComponentRef<PopoverComponent> {
this.viewContainer.clear();
this._contentRef = this._getContentRef(this.ssPopover);
const PopoverComponentFactory =
this.componentFactoryResolver.resolveComponentFactory(PopoverComponent);
const PopoverComponentRef = this.viewContainer.createComponent(PopoverComponentFactory, 0, this._injector,
this._contentRef.nodes);
PopoverComponentRef.instance.title = title;
return PopoverComponentRef;
}
private _getContentRef(content: string | TemplateRef<any>, context?: any): ContentRef {
if (!content) {
return new ContentRef([]);
} else if (content instanceof TemplateRef) {
const viewRef = this.viewContainer.createEmbeddedView(content);
return new ContentRef([viewRef.rootNodes], viewRef);
} else {
return new ContentRef([[this.render.createText(`${content}`)]]);
}
}
private getTargetLocation(): Point {
const box = this.element.getBoundingClientRect();
return new Point(this.element.offsetParent.clientWidth - this.element.offsetLeft - box.width, this.element.offsetTop + box.height / 2);
}
ngOnInit(): void {
this.element = this.elementRef.nativeElement;
this.unlistenGlobal = this.render.listen('document', 'click', (evt) => {
this.removePopover();
});
}
ngOnDestroy() {
this.unlistenGlobal();
if (this.unlistenPopover) {
this.unlistenPopover();
}
}
}
popover.component.ts
import { Component, OnInit, Input, TemplateRef } from '@angular/core';
@Component({
selector: 'app-popover',
templateUrl: './popover.component.html',
styleUrls: ['./popover.component.css']
})
export class PopoverComponent implements OnInit {
@Input() title: undefined | string | TemplateRef<any>;
constructor() { }
ngOnInit() {
}
stopP($event) {
$event.stopPropagation();
}
}
popover.component.html
<div class="popover bottom animated pulse" (click)="stopP($event)">
<h3 class="popover-title" *ngIf="title != null">{{title}}</h3>
<div class="popover-content">
<ng-content></ng-content>
</div>
</div>
popover.component.css
.popover {
position: absolute;
top: 0;
right: 0;
z-index: 1060;
display: none;
max- 276px;
padding: 1px;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-style: normal;
font-weight: normal;
letter-spacing: normal;
line-break: auto;
line-height: 1.42857143;
text-align: left;
text-align: start;
text-decoration: none;
text-shadow: none;
text-transform: none;
white-space: normal;
word-break: normal;
word-spacing: normal;
word-wrap: normal;
font-size: 14px;
cursor:default;
background-color: #ffffff;
-webkit-background-clip: padding-box;
background-clip: padding-box;
border-radius: 4px;
-webkit-box-shadow: 0 2px 8px rgba(0,0,0,.15);
box-shadow: 0 2px 8px rgba(0,0,0,.15);
}
.popover.bottom {
margin-top: 10px;
}
.popover-title {
margin: 0;
padding: 8px 14px;
font-size: 14px;
border-bottom: 1px solid #e8e8e8;
}
.popover-content {
padding: 9px 14px;
}
.popover > .arrow,
.popover > .arrow:after {
position: absolute;
display: block;
0;
height: 0;
border-color: transparent;
border-style: solid;
}
.popover > .arrow {
border- 6px;
}
.popover > .arrow:after {
border- 5px;
content: "";
}
.popover.bottom > .arrow {
left: 30%;
margin-left: -16px;
border-top- 0;
border-bottom-color: rgba(0,0,0,.07);
top: -6px;
}
.popover.bottom > .arrow:after {
content: " ";
top: 1px;
margin-left: -5px;
border-top- 0;
border-bottom-color: #ffffff;
}
.clearfix:before,
.clearfix:after {
content: " ";
display: table;
}
.clearfix:after {
clear: both;
}
.center-block {
display: block;
margin-left: auto;
margin-right: auto;
}
.pull-right {
float: right !important;
}
.pull-left {
float: left !important;
}
.hide {
display: none !important;
}
.show {
display: block !important;
}
.invisible {
visibility: hidden;
}
.text-hide {
font: 0/0 a;
color: transparent;
text-shadow: none;
background-color: transparent;
border: 0;
}
.hidden {
display: none !important;
}
.affix {
position: fixed;
}
例如,在role模块使用自定义popover:
1、在role模块引入PopoverModule。
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgZorroAntdModule } from 'ng-zorro-antd';
import { CapellaRoleRoutes } from './capella-role.routing';
import { CapellaRoleComponent } from './capella-role.component';
import { RoleListComponent } from './role-list/role-list.component';
import { RoleDetailsComponent } from './role-details/role-details.component';
import { PopoverModule } from '../common/popover/popover.module';
@NgModule({
imports: [
CommonModule,
NgZorroAntdModule,
CapellaRoleRoutes,
PopoverModule
],
declarations: [
CapellaRoleComponent,
RoleListComponent,
RoleDetailsComponent,
]
})
export class CapellaRoleModule { }
2、以指令方式使用。
<div nz-row [nzGutter]="8">
<div nz-col [nzSpan]="8" *ngFor="let item of cardlist.items">
<nz-card nzTitle="{{item.roleName}}" class="ss-card-box" [nzExtra]="extraTemplate" (click)="openDetails(item)">
<p class="ss-card-content">{{item.description}}</p>
<p>{{item.createAt}}</p>
</nz-card>
</div>
</div>
<ng-template #extraTemplate >
<span class="ss-popover-icon" [ssPopover]="extraPopover">
<i class="anticon anticon-ellipsis"></i>
</span>
</ng-template>
<ng-template #extraPopover >
<ul class="ss-popover-itemsBox">
<li class="ss-popover-item"><a routerLink="../roleDetails" class="ss-popover-item-link"><i class="anticon anticon-edit"></i>编辑</a></li>
<li class="ss-popover-item"><a (click)="showDeleteConfirm()" class="ss-popover-item-link"><i class="anticon anticon-delete"></i>删除</a></li>
</ul>
</ng-template>