diff --git a/package.json b/package.json index 7ddbbcf658..f4d82d07bc 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "@angular/cli": "^6.0.0", "@angular/compiler-cli": "6.0.0", "@angular/language-service": "6.0.0", + "@angular/cdk": "6.0.0", "@types/gulp": "3.8.36", "@types/highlight.js": "9.12.2", "@types/jasmine": "2.8.3", diff --git a/scripts/gulp/tasks/bundle/rollup-config.ts b/scripts/gulp/tasks/bundle/rollup-config.ts index edcca4e1b1..61e786f55c 100644 --- a/scripts/gulp/tasks/bundle/rollup-config.ts +++ b/scripts/gulp/tasks/bundle/rollup-config.ts @@ -17,6 +17,9 @@ const ROLLUP_GLOBALS = { '@angular/core/testing': 'ng.core.testing', '@angular/common/testing': 'ng.common.testing', '@angular/common/http/testing': 'ng.common.http.testing', + '@angular/cdk/overlay': 'ng.cdk.overlay', + '@angular/cdk/platform': 'ng.cdk.platform', + '@angular/cdk/portal': 'ng.cdk.portal', // RxJS dependencies diff --git a/src/framework/theme/components/cdk/adapter/adapter.module.ts b/src/framework/theme/components/cdk/adapter/adapter.module.ts new file mode 100644 index 0000000000..1981676881 --- /dev/null +++ b/src/framework/theme/components/cdk/adapter/adapter.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { OverlayContainer, ScrollDispatcher } from '@angular/cdk/overlay'; + +import { NbOverlayContainerAdapter } from './overlay-container-adapter'; +import { NbScrollDispatcherAdapter } from './scroll-dispatcher-adapter'; +import { NbViewportRulerAdapter } from './viewport-ruler-adapter'; + + +@NgModule({ + providers: [ + NbViewportRulerAdapter, + { provide: OverlayContainer, useClass: NbOverlayContainerAdapter }, + { provide: ScrollDispatcher, useClass: NbScrollDispatcherAdapter }, + ], +}) +export class NbCdkAdapterModule { +} diff --git a/src/framework/theme/components/cdk/adapter/overlay-container-adapter.ts b/src/framework/theme/components/cdk/adapter/overlay-container-adapter.ts new file mode 100644 index 0000000000..169975896e --- /dev/null +++ b/src/framework/theme/components/cdk/adapter/overlay-container-adapter.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@angular/core'; + +import { NbOverlayContainer } from '../overlay/mapping'; + + +@Injectable() +export class NbOverlayContainerAdapter extends NbOverlayContainer { + protected _createContainer(): void { + const container = this._document.createElement('div'); + + container.classList.add('cdk-overlay-container'); + this._document.querySelector('nb-layout').appendChild(container); + this._containerElement = container; + } +} diff --git a/src/framework/theme/components/cdk/adapter/scroll-dispatcher-adapter.ts b/src/framework/theme/components/cdk/adapter/scroll-dispatcher-adapter.ts new file mode 100644 index 0000000000..6a0a3cc655 --- /dev/null +++ b/src/framework/theme/components/cdk/adapter/scroll-dispatcher-adapter.ts @@ -0,0 +1,18 @@ +import { Injectable, NgZone } from '@angular/core'; +import { CdkScrollable, ScrollDispatcher } from '@angular/cdk/overlay'; +import { Observable } from 'rxjs'; + +import { NbPlatform } from '../overlay/mapping'; +import { NbLayoutScrollService } from '../../../services/scroll.service'; + +@Injectable() +export class NbScrollDispatcherAdapter extends ScrollDispatcher { + constructor(ngZone: NgZone, platform: NbPlatform, protected scrollService: NbLayoutScrollService) { + super(ngZone, platform); + } + + scrolled(auditTimeInMs?: number): Observable { + return this.scrollService.onScroll(); + } +} + diff --git a/src/framework/theme/components/cdk/adapter/viewport-ruler-adapter.ts b/src/framework/theme/components/cdk/adapter/viewport-ruler-adapter.ts new file mode 100644 index 0000000000..4298dda350 --- /dev/null +++ b/src/framework/theme/components/cdk/adapter/viewport-ruler-adapter.ts @@ -0,0 +1,41 @@ +import { Injectable, NgZone } from '@angular/core'; +import { ViewportRuler } from '@angular/cdk/overlay'; +import { map } from 'rxjs/operators'; + +import { NbPlatform } from '../overlay/mapping'; +import { NbLayoutRulerService } from '../../../services/ruler.service'; +import { NbLayoutScrollService, NbScrollPosition } from '../../../services/scroll.service'; + + +@Injectable() +export class NbViewportRulerAdapter extends ViewportRuler { + constructor(platform: NbPlatform, ngZone: NgZone, + protected ruler: NbLayoutRulerService, + protected scroll: NbLayoutScrollService) { + super(platform, ngZone); + } + + getViewportSize(): Readonly<{ width: number; height: number; }> { + let res; + /* + * getDimensions call is really synchronous operation. + * And we have to conform with the interface of the original service. + * */ + this.ruler.getDimensions() + .pipe(map(dimensions => ({ width: dimensions.clientWidth, height: dimensions.clientHeight }))) + .subscribe(rect => res = rect); + return res; + } + + getViewportScrollPosition(): { left: number; top: number } { + let res; + /* + * getPosition call is really synchronous operation. + * And we have to conform with the interface of the original service. + * */ + this.scroll.getPosition() + .pipe(map((position: NbScrollPosition) => ({ top: position.y, left: position.x }))) + .subscribe(position => res = position); + return res; + } +} diff --git a/src/framework/theme/components/cdk/index.ts b/src/framework/theme/components/cdk/index.ts new file mode 100644 index 0000000000..00f4f1c674 --- /dev/null +++ b/src/framework/theme/components/cdk/index.ts @@ -0,0 +1,2 @@ +export * from './overlay/mapping'; +export * from './overlay'; diff --git a/src/framework/theme/components/cdk/overlay/_overlay.theme.scss b/src/framework/theme/components/cdk/overlay/_overlay.theme.scss new file mode 100644 index 0000000000..5a487f936f --- /dev/null +++ b/src/framework/theme/components/cdk/overlay/_overlay.theme.scss @@ -0,0 +1,7 @@ +@import '~@angular/cdk/overlay-prebuilt.css'; + +@mixin nb-overlay-theme { + .overlay-backdrop { + background: nb-theme(overlay-backdrop-bg); + } +} diff --git a/src/framework/theme/components/cdk/overlay/index.ts b/src/framework/theme/components/cdk/overlay/index.ts new file mode 100644 index 0000000000..e5584a46ad --- /dev/null +++ b/src/framework/theme/components/cdk/overlay/index.ts @@ -0,0 +1,6 @@ +export * from './overlay.module'; +export * from './overlay'; +export * from './overlay-position'; +export * from './overlay-container'; +export * from './overlay-trigger'; +export * from './mapping'; diff --git a/src/framework/theme/components/cdk/overlay/mapping.ts b/src/framework/theme/components/cdk/overlay/mapping.ts new file mode 100644 index 0000000000..0b669528ec --- /dev/null +++ b/src/framework/theme/components/cdk/overlay/mapping.ts @@ -0,0 +1,82 @@ +import { Directive, Injectable, NgModule, TemplateRef, ViewContainerRef } from '@angular/core'; +import { CdkPortal, ComponentPortal, Portal, PortalModule, TemplatePortal } from '@angular/cdk/portal'; +import { + ComponentType, + ConnectedOverlayPositionChange, + ConnectedPosition, + ConnectionPositionPair, + FlexibleConnectedPositionStrategy, + GlobalPositionStrategy, + Overlay, + OverlayContainer, + OverlayModule, + OverlayPositionBuilder, + OverlayRef, + PositionStrategy, +} from '@angular/cdk/overlay'; +import { Platform } from '@angular/cdk/platform'; + + +@Directive({ selector: '[nbPortal]' }) +export class NbPortalDirective extends CdkPortal { +} + +@Injectable() +export class NbOverlayService extends Overlay { +} + +@Injectable() +export class NbPlatform extends Platform { +} + +@Injectable() +export class NbOverlayPositionBuilder extends OverlayPositionBuilder { +} + +export class NbComponentPortal extends ComponentPortal { +} + +export class NbTemplatePortal extends TemplatePortal { + constructor(template: TemplateRef, viewContainerRef?: ViewContainerRef, context?: T) { + super(template, viewContainerRef, context); + } +} + +export class NbOverlayContainer extends OverlayContainer { +} + +export class NbFlexibleConnectedPositionStrategy extends FlexibleConnectedPositionStrategy { +} + +export type NbPortal = Portal; +export type NbOverlayRef = OverlayRef; +export type NbComponentType = ComponentType; +export type NbGlobalPositionStrategy = GlobalPositionStrategy; +export type NbPositionStrategy = PositionStrategy; +export type NbConnectedPosition = ConnectedPosition; +export type NbConnectedOverlayPositionChange = ConnectedOverlayPositionChange; +export type NbConnectionPositionPair = ConnectionPositionPair; + +const CDK_MODULES = [OverlayModule, PortalModule]; + +const CDK_PROVIDERS = [ + NbOverlayService, + NbPlatform, + NbOverlayPositionBuilder, +]; + +/** + * This module helps us to keep all angular/cdk deps inside our cdk module via providing aliases. + * Approach will help us move cdk in separate npm package and refactor nebular/theme code. + * */ +@NgModule({ + imports: [...CDK_MODULES], + exports: [ + ...CDK_MODULES, + NbPortalDirective, + ], + declarations: [NbPortalDirective], + providers: [...CDK_PROVIDERS], +}) +export class NbCdkMappingModule { +} diff --git a/src/framework/theme/components/cdk/overlay/overlay-container.ts b/src/framework/theme/components/cdk/overlay/overlay-container.ts new file mode 100644 index 0000000000..c0eef1b250 --- /dev/null +++ b/src/framework/theme/components/cdk/overlay/overlay-container.ts @@ -0,0 +1,90 @@ +import { ChangeDetectorRef, Component, HostBinding, Input, TemplateRef, Type, ViewChild } from '@angular/core'; +import { NgComponentOutlet } from '@angular/common'; + +import { NbPosition } from './overlay-position'; + +export abstract class NbPositionedContainer { + @Input() position: NbPosition; + + @HostBinding('class.nb-overlay-top') + get top(): boolean { + return this.position === NbPosition.TOP + } + + @HostBinding('class.nb-overlay-right') + get right(): boolean { + return this.position === NbPosition.RIGHT + } + + @HostBinding('class.nb-overlay-bottom') + get bottom(): boolean { + return this.position === NbPosition.BOTTOM + } + + @HostBinding('class.nb-overlay-left') + get left(): boolean { + return this.position === NbPosition.LEFT + } +} + +@Component({ + selector: 'nb-overlay-container', + template: ` + + + + + +
{{content}}
+
+ `, +}) +export class NbOverlayContainerComponent { + @Input() + content: any; + + @Input() + context: Object; + + constructor(private cd: ChangeDetectorRef) { + } + + @ViewChild(NgComponentOutlet) + set componentOutlet(el) { + if (this.isComponent) { + Object.assign(el._componentRef.instance, this.context); + /** + * Change detection has to be performed here, because another way applied context + * will be rendered on the next change detection loop and + * we'll have incorrect positioning. Because rendered component may change its size + * based on the context. + * */ + this.cd.detectChanges(); + } + } + + /** + * Check that content is a TemplateRef. + * + * @return boolean + * */ + get isTemplate(): boolean { + return this.content instanceof TemplateRef; + } + + /** + * Check that content is an angular component. + * + * @return boolean + * */ + get isComponent(): boolean { + return this.content instanceof Type; + } + + /** + * Check that if content is not a TemplateRef or an angular component it means a primitive. + * */ + get isPrimitive(): boolean { + return !this.isTemplate && !this.isComponent; + } +} diff --git a/src/framework/theme/components/cdk/overlay/overlay-position.ts b/src/framework/theme/components/cdk/overlay/overlay-position.ts new file mode 100644 index 0000000000..f482a17774 --- /dev/null +++ b/src/framework/theme/components/cdk/overlay/overlay-position.ts @@ -0,0 +1,163 @@ +import { ElementRef, Inject, Injectable } from '@angular/core'; + +import { map } from 'rxjs/operators'; +import { Observable } from 'rxjs'; + +import { NB_DOCUMENT } from '../../../theme.options'; +import { + NbConnectedOverlayPositionChange, + NbConnectedPosition, + NbConnectionPositionPair, + NbFlexibleConnectedPositionStrategy, + NbGlobalPositionStrategy, + NbOverlayPositionBuilder, + NbOverlayRef, + NbPlatform, + NbPositionStrategy, +} from './mapping'; +import { NbViewportRulerAdapter } from '../adapter/viewport-ruler-adapter'; + + +export enum NbAdjustment { + NOOP = 'noop', + CLOCKWISE = 'clockwise', + COUNTERCLOCKWISE = 'counterclockwise', +} + +export enum NbPosition { + TOP = 'top', + BOTTOM = 'bottom', + LEFT = 'left', + RIGHT = 'right', + START = 'start', + END = 'end', +} + +const POSITIONS = { + [NbPosition.RIGHT](offset) { + return { originX: 'end', originY: 'center', overlayX: 'start', overlayY: 'center', offsetX: offset }; + }, + [NbPosition.BOTTOM](offset) { + return { originX: 'center', originY: 'bottom', overlayX: 'center', overlayY: 'top', offsetY: offset }; + }, + [NbPosition.LEFT](offset) { + return { originX: 'start', originY: 'center', overlayX: 'end', overlayY: 'center', offsetX: -offset }; + }, + [NbPosition.TOP](offset) { + return { originX: 'center', originY: 'top', overlayX: 'center', overlayY: 'bottom', offsetY: -offset }; + }, +}; + +const COUNTER_CLOCKWISE_POSITIONS = [NbPosition.TOP, NbPosition.LEFT, NbPosition.BOTTOM, NbPosition.RIGHT]; +const NOOP_POSITIONS = [NbPosition.TOP, NbPosition.BOTTOM, NbPosition.LEFT, NbPosition.RIGHT]; +const CLOCKWISE_POSITIONS = [NbPosition.TOP, NbPosition.RIGHT, NbPosition.BOTTOM, NbPosition.LEFT]; + + +function comparePositions(p1: NbConnectedPosition, p2: NbConnectedPosition): boolean { + return p1.originX === p2.originX + && p1.originY === p2.originY + && p1.overlayX === p2.overlayX + && p1.overlayY === p2.overlayY; +} + +/** + * The main idea of the adjustable connected strategy is to provide predefined set of positions for your overlay. + * You have to provide adjustment and appropriate strategy will be chosen in runtime. + * */ +export class NbAdjustableConnectedPositionStrategy + extends NbFlexibleConnectedPositionStrategy implements NbPositionStrategy { + + protected _position: NbPosition; + protected _offset: number = 15; + protected _adjustment: NbAdjustment; + + protected appliedPositions: { key: NbPosition, connectedPosition: NbConnectedPosition }[]; + + readonly positionChange: Observable = this.positionChanges.pipe( + map((positionChange: NbConnectedOverlayPositionChange) => positionChange.connectionPair), + map((connectionPair: NbConnectionPositionPair) => { + return this.appliedPositions.find(({ connectedPosition }) => { + return comparePositions(connectedPosition, connectionPair); + }).key; + }), + ); + + attach(overlayRef: NbOverlayRef) { + /** + * We have to apply positions before attach because super.attach() validates positions and crashes app + * if no positions provided. + * */ + this.applyPositions(); + super.attach(overlayRef); + } + + apply() { + this.applyPositions(); + super.apply(); + } + + position(position: NbPosition): this { + this._position = position; + return this; + } + + adjustment(adjustment: NbAdjustment): this { + this._adjustment = adjustment; + return this; + } + + offset(offset: number): this { + this._offset = offset; + return this; + } + + protected applyPositions() { + const positions: NbPosition[] = this.createPositions(); + this.persistChosenPositions(positions); + this.withPositions(this.appliedPositions.map(({ connectedPosition }) => connectedPosition)); + } + + protected createPositions(): NbPosition[] { + switch (this._adjustment) { + case NbAdjustment.NOOP: + return NOOP_POSITIONS.filter(position => this._position === position); + case NbAdjustment.CLOCKWISE: + return this.reorderPreferredPositions(CLOCKWISE_POSITIONS); + case NbAdjustment.COUNTERCLOCKWISE: + return this.reorderPreferredPositions(COUNTER_CLOCKWISE_POSITIONS); + } + } + + protected persistChosenPositions(positions: NbPosition[]) { + this.appliedPositions = positions.map(position => ({ + key: position, + connectedPosition: POSITIONS[position](this._offset), + })); + } + + protected reorderPreferredPositions(positions: NbPosition[]): NbPosition[] { + const cpy = positions.slice(); + const startIndex = positions.indexOf(this._position); + const start = cpy.splice(startIndex); + return start.concat(...cpy); + } +} + +@Injectable() +export class NbPositionBuilderService { + constructor(@Inject(NB_DOCUMENT) protected document, + protected viewportRuler: NbViewportRulerAdapter, + protected platform: NbPlatform, + protected positionBuilder: NbOverlayPositionBuilder) { + } + + global(): NbGlobalPositionStrategy { + return this.positionBuilder.global(); + } + + connectedTo(elementRef: ElementRef): NbAdjustableConnectedPositionStrategy { + return new NbAdjustableConnectedPositionStrategy(elementRef, this.viewportRuler, this.document, this.platform) + .withFlexibleDimensions(false) + .withPush(false); + } +} diff --git a/src/framework/theme/components/cdk/overlay/overlay-trigger.spec.ts b/src/framework/theme/components/cdk/overlay/overlay-trigger.spec.ts new file mode 100644 index 0000000000..fa81a172ee --- /dev/null +++ b/src/framework/theme/components/cdk/overlay/overlay-trigger.spec.ts @@ -0,0 +1,167 @@ +import { DOCUMENT } from '@angular/common'; +import { TestBed } from '@angular/core/testing'; +import { ComponentRef } from '@angular/core'; + +import { NbTrigger, NbTriggerStrategyBuilder } from './overlay-trigger'; +import { NB_DOCUMENT } from '../../../theme.options'; + + +describe('click-trigger-strategy', () => { + let triggerStrategyBuilder: NbTriggerStrategyBuilder; + let document: Document; + let host: HTMLElement; + let container: HTMLElement; + + const withContainer = el => () => ({ location: { nativeElement: el } }) as ComponentRef; + + const click = el => el.dispatchEvent(new Event('click')); + + const createElement = () => document.createElement('div'); + + beforeEach(() => { + const bed = TestBed.configureTestingModule({ providers: [{ provide: NB_DOCUMENT, useExisting: DOCUMENT }] }); + document = bed.get(NB_DOCUMENT); + }); + + beforeEach(() => { + host = createElement(); + container = createElement(); + triggerStrategyBuilder = new NbTriggerStrategyBuilder() + .document(document) + .trigger(NbTrigger.CLICK) + .host(host) + .container(withContainer(container)); + }); + + it('should fire show$ when click on host without container', done => { + const triggerStrategy = triggerStrategyBuilder + .container(() => null) + .build(); + + triggerStrategy.show$.subscribe(done); + + click(host); + }); + + it('should fire hide$ when click on host with container', done => { + const triggerStrategy = triggerStrategyBuilder.build(); + triggerStrategy.hide$.subscribe(done); + click(host); + }); + + it('should fire hide$ when click on document', done => { + const triggerStrategy = triggerStrategyBuilder.build(); + triggerStrategy.hide$.subscribe(done); + click(document); + }); + + it('should not fire hide$ when click on container', () => { + const triggerStrategy = triggerStrategyBuilder.build(); + const spy = jasmine.createSpy(); + + triggerStrategy.hide$.subscribe(spy); + click(container); + + expect(spy).toHaveBeenCalledTimes(0); + }); +}); + +describe('hover-trigger-strategy', () => { + let triggerStrategyBuilder: NbTriggerStrategyBuilder; + let document: Document; + let host: HTMLElement; + let container: HTMLElement; + + const withContainer = el => () => ({ location: { nativeElement: el } }) as ComponentRef; + + const mouseMove = el => el.dispatchEvent(new Event('mousemove')); + + const mouseEnter = el => el.dispatchEvent(new Event('mouseenter')); + + const mouseLeave = el => el.dispatchEvent(new Event('mouseleave')); + + const createElement = () => document.createElement('div'); + + beforeEach(() => { + const bed = TestBed.configureTestingModule({ providers: [{ provide: NB_DOCUMENT, useExisting: DOCUMENT }] }); + document = bed.get(NB_DOCUMENT); + }); + + beforeEach(() => { + host = createElement(); + container = createElement(); + triggerStrategyBuilder = new NbTriggerStrategyBuilder() + .document(document) + .trigger(NbTrigger.HOVER) + .host(host) + .container(withContainer(container)); + }); + + it('should fire show$ when hover on host', done => { + const triggerStrategy = triggerStrategyBuilder.build(); + triggerStrategy.show$.subscribe(done); + mouseEnter(host); + }); + + it('should fire hide$ when hover out of host', done => { + const triggerStrategy = triggerStrategyBuilder.build(); + triggerStrategy.hide$.subscribe(done); + mouseEnter(host); + mouseLeave(host); + mouseMove(document); + }); + + it('should not fire hide$ when hover out to container', () => { + const triggerStrategy = triggerStrategyBuilder.build(); + const spy = jasmine.createSpy(); + triggerStrategy.hide$.subscribe(spy); + + mouseEnter(host); + mouseLeave(host); + mouseMove(container); + + expect(spy).toHaveBeenCalledTimes(0); + }); +}); + +describe('hint-trigger-strategy', () => { + let triggerStrategyBuilder: NbTriggerStrategyBuilder; + let document: Document; + let host: HTMLElement; + let container: HTMLElement; + + const withContainer = el => () => ({ location: { nativeElement: el } }) as ComponentRef; + + const mouseEnter = el => el.dispatchEvent(new Event('mouseenter')); + + const mouseLeave = el => el.dispatchEvent(new Event('mouseleave')); + + const createElement = () => document.createElement('div'); + + beforeEach(() => { + const bed = TestBed.configureTestingModule({ providers: [{ provide: NB_DOCUMENT, useExisting: DOCUMENT }] }); + document = bed.get(NB_DOCUMENT); + }); + + beforeEach(() => { + host = createElement(); + container = createElement(); + triggerStrategyBuilder = new NbTriggerStrategyBuilder() + .document(document) + .trigger(NbTrigger.HINT) + .host(host) + .container(withContainer(container)); + }); + + it('should fire show$ when hover on host', done => { + const triggerStrategy = triggerStrategyBuilder.build(); + triggerStrategy.show$.subscribe(done); + mouseEnter(host); + }); + + it('should fire hide$ when hover out from host', done => { + const triggerStrategy = triggerStrategyBuilder.build(); + triggerStrategy.hide$.subscribe(done); + mouseLeave(host); + }); +}); diff --git a/src/framework/theme/components/cdk/overlay/overlay-trigger.ts b/src/framework/theme/components/cdk/overlay/overlay-trigger.ts new file mode 100644 index 0000000000..3a0605948d --- /dev/null +++ b/src/framework/theme/components/cdk/overlay/overlay-trigger.ts @@ -0,0 +1,151 @@ +import { fromEvent as observableFromEvent, merge as observableMerge, Observable, Subject } from 'rxjs'; +import { debounceTime, delay, filter, repeat, switchMap, takeUntil, takeWhile } from 'rxjs/operators'; +import { ComponentRef } from '@angular/core'; + + +export enum NbTrigger { + CLICK = 'click', + HOVER = 'hover', + HINT = 'hint', +} + +/** + * Provides entity with three event stream: show, hide and toggle. + * Each stream provides different events depends on implementation. + * We have three main trigger strategies: click, hint and hover. + * */ +export abstract class NbTriggerStrategy { + abstract show$: Observable; + abstract hide$: Observable; + + constructor(protected document: Document, protected host: HTMLElement, protected container: () => ComponentRef) { + } +} + +/** + * Creates toggle and close events streams, show stream is empty. + * Fires toggle event when the click was performed on the host element. + * Fires close event when the click was performed on the document but + * not on the host or container. + * */ +export class NbClickTriggerStrategy extends NbTriggerStrategy { + protected show: Subject = new Subject(); + readonly show$: Observable = this.show.asObservable(); + + protected hide: Subject = new Subject(); + readonly hide$: Observable = observableMerge( + this.hide.asObservable(), + observableFromEvent(this.document, 'click') + .pipe(filter((event: Event) => this.isNotHostOrContainer(event))), + ); + + constructor(protected document: Document, protected host: HTMLElement, protected container: () => ComponentRef) { + super(document, host, container); + this.subscribeOnHostClick(); + } + + protected subscribeOnHostClick() { + observableFromEvent(this.host, 'click') + .subscribe((event: Event) => { + if (this.isContainerExists()) { + this.hide.next(event); + } else { + this.show.next(event); + } + }) + } + + protected isContainerExists(): boolean { + return !!this.container(); + } + + protected isNotHostOrContainer(event: Event): boolean { + return !this.host.contains(event.target as Node) + && this.isContainerExists() + && !this.container().location.nativeElement.contains(event.target as Node); + } +} + +/** + * Creates open and close events streams, the toggle is empty. + * Fires open event when a mouse hovers over the host element and stay over at least 100 milliseconds. + * Fires close event when the mouse leaves the host element and stops out of the host and popover container. + * */ +export class NbHoverTriggerStrategy extends NbTriggerStrategy { + + show$: Observable = observableFromEvent(this.host, 'mouseenter') + .pipe( + delay(100), + takeUntil(observableFromEvent(this.host, 'mouseleave')), + repeat(), + ); + + hide$: Observable = observableFromEvent(this.host, 'mouseleave') + .pipe( + switchMap(() => observableFromEvent(this.document, 'mousemove') + .pipe( + debounceTime(100), + takeWhile(() => !!this.container()), + filter(event => !this.host.contains(event.target as Node) + && !this.container().location.nativeElement.contains(event.target as Node), + ), + ), + ), + ); +} + +/** + * Creates open and close events streams, the toggle is empty. + * Fires open event when a mouse hovers over the host element and stay over at least 100 milliseconds. + * Fires close event when the mouse leaves the host element. + * */ +export class NbHintTriggerStrategy extends NbTriggerStrategy { + show$: Observable = observableFromEvent(this.host, 'mouseenter') + .pipe( + delay(100), + takeUntil(observableFromEvent(this.host, 'mouseleave')), + repeat(), + ); + + hide$: Observable = observableFromEvent(this.host, 'mouseleave'); +} + +export class NbTriggerStrategyBuilder { + protected _host: HTMLElement; + protected _container: () => ComponentRef; + protected _trigger: NbTrigger; + protected _document: Document; + + document(document: Document): this { + this._document = document; + return this; + } + + trigger(trigger: NbTrigger): this { + this._trigger = trigger; + return this; + } + + host(host: HTMLElement): this { + this._host = host; + return this; + } + + container(container: () => ComponentRef): this { + this._container = container; + return this; + } + + build(): NbTriggerStrategy { + switch (this._trigger) { + case NbTrigger.CLICK: + return new NbClickTriggerStrategy(this._document, this._host, this._container); + case NbTrigger.HINT: + return new NbHintTriggerStrategy(this._document, this._host, this._container); + case NbTrigger.HOVER: + return new NbHoverTriggerStrategy(this._document, this._host, this._container); + default: + throw new Error('Trigger have to be provided'); + } + } +} diff --git a/src/framework/theme/components/cdk/overlay/overlay.module.ts b/src/framework/theme/components/cdk/overlay/overlay.module.ts new file mode 100644 index 0000000000..94bef9d7c5 --- /dev/null +++ b/src/framework/theme/components/cdk/overlay/overlay.module.ts @@ -0,0 +1,31 @@ +import { ModuleWithProviders, NgModule } from '@angular/core'; + +import { NbSharedModule } from '../../shared/shared.module'; +import { NbCdkMappingModule } from './mapping'; +import { NbCdkAdapterModule } from '../adapter/adapter.module'; +import { NbPositionBuilderService } from './overlay-position'; +import { NbOverlayContainerComponent } from './overlay-container'; + + +@NgModule({ + imports: [ + NbCdkMappingModule, + NbCdkAdapterModule, + NbSharedModule, + ], + declarations: [NbOverlayContainerComponent], + exports: [ + NbCdkMappingModule, + NbOverlayContainerComponent, + ], +}) +export class NbOverlayModule { + static forRoot(): ModuleWithProviders { + return { + ngModule: NbOverlayModule, + providers: [ + NbPositionBuilderService, + ], + }; + } +} diff --git a/src/framework/theme/components/cdk/overlay/overlay.ts b/src/framework/theme/components/cdk/overlay/overlay.ts new file mode 100644 index 0000000000..98bfc15906 --- /dev/null +++ b/src/framework/theme/components/cdk/overlay/overlay.ts @@ -0,0 +1,18 @@ +import { ComponentRef, TemplateRef, Type } from '@angular/core'; + +import { NbComponentPortal, NbComponentType, NbOverlayRef } from './mapping'; + + +export type NbOverlayContent = Type | TemplateRef | string; + +export function patch(container: ComponentRef, containerContext: Object): ComponentRef { + Object.assign(container.instance, containerContext); + container.changeDetectorRef.detectChanges(); + return container; +} + +export function createContainer(ref: NbOverlayRef, container: NbComponentType, context: Object): ComponentRef { + const containerRef = ref.attach(new NbComponentPortal(container)); + patch(containerRef, context); + return containerRef; +} diff --git a/src/framework/theme/index.ts b/src/framework/theme/index.ts index 5ca9e9297e..6bfba856fd 100644 --- a/src/framework/theme/index.ts +++ b/src/framework/theme/index.ts @@ -70,3 +70,4 @@ export * from './components/list/list-page-tracker.directive'; export * from './components/list/infinite-list.directive'; export * from './components/input/input.directive'; export * from './components/input/input.module'; +export * from './components/cdk/overlay'; diff --git a/src/framework/theme/package.json b/src/framework/theme/package.json index 54879a6d8c..12f8da6290 100644 --- a/src/framework/theme/package.json +++ b/src/framework/theme/package.json @@ -28,6 +28,7 @@ "@angular/common": "^6.0.0", "@angular/core": "^6.0.0", "@angular/router": "^6.0.0", + "@angular/cdk": "^6.0.0", "rxjs": "^6.1.0", "bootstrap": "^4.0.0" }, diff --git a/src/framework/theme/styles/global/_components.scss b/src/framework/theme/styles/global/_components.scss index 91946e89a0..520045a18f 100644 --- a/src/framework/theme/styles/global/_components.scss +++ b/src/framework/theme/styles/global/_components.scss @@ -30,6 +30,7 @@ @import '../../components/button/button.component.theme'; @import '../../components/list/list.component.theme'; @import '../../components/input/input.directive.theme'; +@import '../../components/cdk/overlay/overlay.theme'; @mixin nb-theme-components() { @@ -59,4 +60,5 @@ @include nb-buttons-theme(); @include nb-list-theme(); @include nb-input-theme(); + @include nb-overlay-theme(); } diff --git a/src/framework/theme/styles/themes/_default.scss b/src/framework/theme/styles/themes/_default.scss index 590dd5713c..dda0ad46a1 100644 --- a/src/framework/theme/styles/themes/_default.scss +++ b/src/framework/theme/styles/themes/_default.scss @@ -628,6 +628,8 @@ $theme: ( calendar-month-cell-large-height: 2.375rem, calendar-year-cell-large-width: calendar-month-cell-width, calendar-year-cell-large-height: calendar-month-cell-height, + + overlay-backdrop-bg: rgba(0, 0, 0, 0.288), ); // register the theme diff --git a/src/playground/overlay/overlay-showcase.component.ts b/src/playground/overlay/overlay-showcase.component.ts new file mode 100644 index 0000000000..2ada3b6692 --- /dev/null +++ b/src/playground/overlay/overlay-showcase.component.ts @@ -0,0 +1,43 @@ +import { Component, OnInit, TemplateRef, ViewChild, ViewContainerRef } from '@angular/core'; +import { NbOverlayRef, NbOverlayService, NbPositionBuilderService, NbTemplatePortal } from '@nebular/theme'; + +@Component({ + selector: 'nb-overlay-showcase', + template: ` + + + This is overlay + + + + + + + `, +}) +export class NbOverlayShowcaseComponent implements OnInit { + @ViewChild('overlay') overlayTemplate: TemplateRef; + protected ref: NbOverlayRef; + + constructor(protected overlay: NbOverlayService, + protected positionBuilder: NbPositionBuilderService, + protected vcr: ViewContainerRef) { + } + + ngOnInit() { + const positionStrategy = this.positionBuilder.global().centerHorizontally().centerVertically(); + this.ref = this.overlay.create({ positionStrategy, hasBackdrop: true }); + } + + createOverlay() { + if (this.ref.hasAttached()) { + return; + } + + this.ref.attach(new NbTemplatePortal(this.overlayTemplate, this.vcr)); + } + + dismissOverlay() { + this.ref.detach(); + } +} diff --git a/src/playground/playground-routing.module.ts b/src/playground/playground-routing.module.ts index 4bd9e6e624..7fe4e8a252 100644 --- a/src/playground/playground-routing.module.ts +++ b/src/playground/playground-routing.module.ts @@ -159,6 +159,7 @@ import { NbCalendarFilterComponent } from './calendar/calendar-filter.component' import { NbCalendarMinMaxComponent } from './calendar/calendar-min-max.component'; import { NbCalendarSizeComponent } from './calendar/calendar-size.component'; import { NbCalendarKitFullCalendarShowcaseComponent } from './calendar-kit/calendar-kit-full-calendar.component'; +import { NbOverlayShowcaseComponent } from './overlay/overlay-showcase.component'; export const routes: Routes = [ @@ -711,6 +712,15 @@ export const routes: Routes = [ }, ], }, + { + path: 'overlay', + children: [ + { + path: 'overlay-showcase.component', + component: NbOverlayShowcaseComponent, + }, + ], + }, ], }, { diff --git a/src/playground/playground.module.ts b/src/playground/playground.module.ts index 6edefe88ce..7d472e7699 100644 --- a/src/playground/playground.module.ts +++ b/src/playground/playground.module.ts @@ -35,6 +35,7 @@ import { NbListModule, NbButtonModule, NbInputModule, + NbOverlayModule, } from '@nebular/theme'; import { NbPlaygroundRoutingModule } from './playground-routing.module'; @@ -199,6 +200,7 @@ import { NbCalendarKitFullCalendarShowcaseComponent, NbCalendarKitMonthCellComponent, } from './calendar-kit/calendar-kit-full-calendar.component'; +import { NbOverlayShowcaseComponent } from './overlay/overlay-showcase.component'; export const NB_MODULES = [ NbCardModule, @@ -232,6 +234,7 @@ export const NB_MODULES = [ NbCalendarModule, NbCalendarRangeModule, NbCalendarKitModule, + NbOverlayModule.forRoot(), ]; export const NB_EXAMPLE_COMPONENTS = [ @@ -385,6 +388,7 @@ export const NB_EXAMPLE_COMPONENTS = [ NbCalendarSizeComponent, NbCalendarKitFullCalendarShowcaseComponent, NbCalendarKitMonthCellComponent, + NbOverlayShowcaseComponent, ]; @NgModule({