From 27b291e79283d8925c44bfc4178b772157840cda Mon Sep 17 00:00:00 2001 From: Dmitry Nehaychik <4dmitr@gmail.com> Date: Wed, 9 Jan 2019 13:45:02 +0300 Subject: [PATCH] feat(context-menu): add `nbContextMenuTrigger` parameter (#1139) Closes #1112 --- src/app/playground-components.ts | 12 + .../context-menu/context-menu.directive.ts | 33 ++- .../context-menu/context-menu.spec.ts | 234 ++++++++++++++++++ .../components/popover/popover.directive.ts | 20 +- .../context-menu-modes.component.html | 15 ++ .../context-menu-modes.component.ts | 28 +++ .../context-menu-noop.component.html | 10 + .../context-menu-noop.component.ts | 43 ++++ .../context-menu-routing.module.ts | 10 + .../context-menu/context-menu.module.ts | 14 +- 10 files changed, 405 insertions(+), 14 deletions(-) create mode 100644 src/framework/theme/components/context-menu/context-menu.spec.ts create mode 100644 src/playground/without-layout/context-menu/context-menu-modes.component.html create mode 100644 src/playground/without-layout/context-menu/context-menu-modes.component.ts create mode 100644 src/playground/without-layout/context-menu/context-menu-noop.component.html create mode 100644 src/playground/without-layout/context-menu/context-menu-noop.component.ts diff --git a/src/app/playground-components.ts b/src/app/playground-components.ts index 547cb6c67b..5d0eb266ef 100644 --- a/src/app/playground-components.ts +++ b/src/app/playground-components.ts @@ -1258,6 +1258,18 @@ export const PLAYGROUND_COMPONENTS: ComponentLink[] = [ component: 'ContextMenuTestComponent', name: 'Context Menu Test', }, + { + path: 'context-menu-modes.component', + link: '/context-menu/context-menu-modes.component', + component: 'ContextMenuModesComponent', + name: 'Context Menu Modes', + }, + { + path: 'context-menu-noop.component', + link: '/context-menu/context-menu-noop.component', + component: 'ContextMenuNoopComponent', + name: 'Context Menu Noop', + }, ], }, { diff --git a/src/framework/theme/components/context-menu/context-menu.directive.ts b/src/framework/theme/components/context-menu/context-menu.directive.ts index 1361bb2235..762727db4f 100644 --- a/src/framework/theme/components/context-menu/context-menu.directive.ts +++ b/src/framework/theme/components/context-menu/context-menu.directive.ts @@ -88,6 +88,23 @@ import { NB_DOCUMENT } from '../../theme.options'; * ```ts * items = [{ title: 'Profile' }, { title: 'Log out' }]; * ``` + * Context menu has a number of triggers which provides an ability to show and hide the component in different ways: + * + * - Click mode shows the component when a user clicks on the host element and hides when the user clicks + * somewhere on the document outside the component. + * - Hint provides capability to show the component when the user hovers over the host element + * and hide when the user hovers out of the host. + * - Hover works like hint mode with one exception - when the user moves mouse from host element to + * the container element the component remains open, so that it is possible to interact with it content. + * - Focus mode is applied when user focuses the element. + * - Noop mode - the component won't react to the user interaction. + * + * @stacked-example(Available Triggers, context-menu/context-menu-modes.component.html) + * + * Noop mode is especially useful when you need to control Popover programmatically, for example show/hide + * as a result of some third-party action, like HTTP request or validation check: + * + * @stacked-example(Manual Control, context-menu/context-menu-noop.component) * */ @Directive({ selector: '[nbContextMenu]' }) export class NbContextMenuDirective implements AfterViewInit, OnDestroy { @@ -122,6 +139,13 @@ export class NbContextMenuDirective implements AfterViewInit, OnDestroy { this.items = items; }; + /** + * Describes when the container will be shown. + * Available options: `click`, `hover`, `hint`, `focus` and `noop` + * */ + @Input('nbContextMenuTrigger') + trigger: NbTrigger = NbTrigger.CLICK; + protected ref: NbOverlayRef; protected container: ComponentRef; protected positionStrategy: NbAdjustableConnectedPositionStrategy; @@ -140,11 +164,15 @@ export class NbContextMenuDirective implements AfterViewInit, OnDestroy { ngAfterViewInit() { this.subscribeOnTriggers(); this.subscribeOnItemClick(); + this.subscribeOnPositionChange(); } ngOnDestroy() { this.alive = false; this.hide(); + if (this.ref) { + this.ref.dispose(); + } } show() { @@ -172,12 +200,10 @@ export class NbContextMenuDirective implements AfterViewInit, OnDestroy { } protected createOverlay() { - this.positionStrategy = this.createPositionStrategy(); this.ref = this.overlay.create({ positionStrategy: this.positionStrategy, scrollStrategy: this.overlay.scrollStrategies.reposition(), }); - this.subscribeOnPositionChange(); } protected openContextMenu() { @@ -197,13 +223,14 @@ export class NbContextMenuDirective implements AfterViewInit, OnDestroy { protected createTriggerStrategy(): NbTriggerStrategy { return this.triggerStrategyBuilder - .trigger(NbTrigger.CLICK) + .trigger(this.trigger) .host(this.hostRef.nativeElement) .container(() => this.container) .build(); } protected subscribeOnPositionChange() { + this.positionStrategy = this.createPositionStrategy(); this.positionStrategy.positionChange .pipe(takeWhile(() => this.alive)) .subscribe((position: NbPosition) => patch(this.container, { position })); diff --git a/src/framework/theme/components/context-menu/context-menu.spec.ts b/src/framework/theme/components/context-menu/context-menu.spec.ts new file mode 100644 index 0000000000..e7f9a9b4b3 --- /dev/null +++ b/src/framework/theme/components/context-menu/context-menu.spec.ts @@ -0,0 +1,234 @@ +import { Component, ComponentRef, ElementRef, Inject, Injectable, Input, NgModule, ViewChild } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { of as observableOf, Subject } from 'rxjs'; + +import { NbThemeModule } from '../../theme.module'; +import { NbLayoutModule } from '../layout/layout.module'; +import { + NbAdjustment, + NbPosition, + NbPositionBuilderService, + NbTrigger, + NbTriggerStrategy, + NbTriggerStrategyBuilderService, +} from '../cdk'; +import { NB_DOCUMENT } from '../../theme.options'; +import { NbContextMenuDirective } from './context-menu.directive'; +import { NbMenuModule } from '../menu/menu.module'; +import { NbContextMenuModule } from './context-menu.module'; + + +@Component({ + selector: 'nb-context-menu-test', + template: ` + + + + + + `, +}) +export class NbContextMenuTestComponent { + @Input() trigger: NbTrigger = NbTrigger.CLICK; + @ViewChild('button') button: ElementRef; + @ViewChild(NbContextMenuDirective) contextMenu: NbContextMenuDirective; + + items = [{ title: 'User' }, { title: 'Log Out' }]; +} + +@NgModule({ + imports: [ + RouterTestingModule.withRoutes([]), + NoopAnimationsModule, + NbThemeModule.forRoot(), + NbLayoutModule, + NbMenuModule.forRoot(), + NbContextMenuModule, + ], + declarations: [ + NbContextMenuTestComponent, + ], +}) +export class ContextMenuTestModule { +} + +export class MockPositionBuilder { + positionChange = new Subject(); + _connectedTo: ElementRef; + _position: NbPosition; + _adjustment: NbAdjustment; + + connectedTo(connectedTo: ElementRef) { + this._connectedTo = connectedTo; + return this; + } + + position(position: NbPosition) { + this._position = position; + return this; + } + + adjustment(adjustment: NbAdjustment) { + this._adjustment = adjustment; + return this; + } + + offset() { + return this; + }; + + attach() { + }; + + apply() { + }; + + detach() { + }; + + dispose() { + }; +} + +@Injectable() +export class MockTriggerStrategyBuilder { + + _host: HTMLElement; + _container: () => ComponentRef; + _trigger: NbTrigger; + + constructor(@Inject(NB_DOCUMENT) public _document: Document) { + } + + 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 { + return { + show$: observableOf(null), + hide$: observableOf(null), + } as NbTriggerStrategy; + } +} + +describe('Directive: NbContextMenuDirective', () => { + beforeEach(() => { + TestBed.configureTestingModule({ imports: [ContextMenuTestModule] }); + }); + + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(NbContextMenuTestComponent); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + }); + + it('should render context menu', () => { + fixture.componentInstance.contextMenu.show(); + fixture.detectChanges(); + + const menu = fixture.nativeElement.querySelector('nb-context-menu nb-menu'); + expect(menu).toBeTruthy(); + }); + + it('should hide', fakeAsync(() => { + let menu; + fixture.componentInstance.contextMenu.show(); + fixture.detectChanges(); + + + menu = fixture.nativeElement.querySelector('nb-context-menu nb-menu'); + expect(menu).toBeTruthy(); + fixture.componentInstance.contextMenu.hide(); + fixture.detectChanges(); + + tick(); // we need this tick for animations + menu = fixture.nativeElement.querySelector('nb-context-menu nb-menu'); + expect(menu).toBeFalsy(); + })); + + it('should toogle', fakeAsync(() => { + let menu; + + fixture.componentInstance.contextMenu.show(); + fixture.detectChanges(); + + menu = fixture.nativeElement.querySelector('nb-context-menu nb-menu'); + expect(menu).toBeTruthy(); + fixture.componentInstance.contextMenu.toggle(); + fixture.detectChanges(); + + tick(); // we need this tick for animations + const tooltip = fixture.nativeElement.querySelector('nb-context-menu'); + expect(tooltip).toBeNull(); + + fixture.componentInstance.contextMenu.toggle(); + fixture.detectChanges(); + tick(); + + menu = fixture.nativeElement.querySelector('nb-context-menu nb-menu'); + expect(menu).toBeTruthy(); + })); + + it('should build position strategy', () => { + const mockPositionBuilder = new MockPositionBuilder(); + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ContextMenuTestModule], + providers: [{ provide: NbPositionBuilderService, useValue: mockPositionBuilder }], + }); + fixture = TestBed.createComponent(NbContextMenuTestComponent); + fixture.detectChanges(); + + expect(mockPositionBuilder._connectedTo.nativeElement).toBe(fixture.componentInstance.button.nativeElement); + expect(mockPositionBuilder._position).toBe(NbPosition.BOTTOM); + expect(mockPositionBuilder._adjustment).toBe(NbAdjustment.CLOCKWISE); + }); + + it('should build with default trigger strategy', () => { + TestBed.resetTestingModule(); + const bed = TestBed.configureTestingModule({ + imports: [ContextMenuTestModule], + providers: [{ provide: NbTriggerStrategyBuilderService, useClass: MockTriggerStrategyBuilder }], + }); + const mockTriggerStrategy = bed.get(NbTriggerStrategyBuilderService); + fixture = TestBed.createComponent(NbContextMenuTestComponent); + fixture.detectChanges(); + + expect(mockTriggerStrategy._trigger).toBe(NbTrigger.CLICK); + }); + + it('should build with custom trigger strategy', () => { + TestBed.resetTestingModule(); + const bed = TestBed.configureTestingModule({ + imports: [ContextMenuTestModule], + providers: [{ provide: NbTriggerStrategyBuilderService, useClass: MockTriggerStrategyBuilder }], + }); + const mockTriggerStrategy = bed.get(NbTriggerStrategyBuilderService); + fixture = TestBed.createComponent(NbContextMenuTestComponent); + fixture.componentInstance.trigger = NbTrigger.HOVER; + fixture.detectChanges(); + + expect(mockTriggerStrategy._trigger).toBe(NbTrigger.HOVER); + }); +}); diff --git a/src/framework/theme/components/popover/popover.directive.ts b/src/framework/theme/components/popover/popover.directive.ts index e9d2620413..e8b8ee0de9 100644 --- a/src/framework/theme/components/popover/popover.directive.ts +++ b/src/framework/theme/components/popover/popover.directive.ts @@ -90,23 +90,23 @@ import { NbPopoverComponent } from './popover.component'; * * ``` * - * Also popover has some different modes which provides capability show$ and hide$ popover in different ways: + * Popover has a number of triggers which provides an ability to show and hide the component in different ways: * - * - Click popover mode shows when a user clicking on the host element and hides when the user clicks - * somewhere on the document except popover. - * - Hint mode provides capability show$ popover when the user hovers on the host element - * and hide$ popover when user hovers out of the host. - * - Hover mode works like hint mode with one exception - when the user moves mouse from host element to - * the container element popover will not be hidden. + * - Click mode shows the component when a user clicks on the host element and hides when the user clicks + * somewhere on the document outside the component. + * - Hint provides capability to show the component when the user hovers over the host element + * and hide when the user hovers out of the host. + * - Hover works like hint mode with one exception - when the user moves mouse from host element to + * the container element the component remains open, so that it is possible to interact with it content. * - Focus mode is applied when user focuses the element. - * - Noop mode - the popover won't react based on user interaction. + * - Noop mode - the component won't react to the user interaction. * - * @stacked-example(Available Modes, popover/popover-modes.component.html) + * @stacked-example(Available Triggers, popover/popover-modes.component.html) * * Noop mode is especially useful when you need to control Popover programmatically, for example show/hide * as a result of some third-party action, like HTTP request or validation check: * - * @stacked-example(Manual control, popover/popover-noop.component) + * @stacked-example(Manual Control, popover/popover-noop.component) * * @additional-example(Template Ref, popover/popover-template-ref.component) * @additional-example(Custom Component, popover/popover-custom-component.component) diff --git a/src/playground/without-layout/context-menu/context-menu-modes.component.html b/src/playground/without-layout/context-menu/context-menu-modes.component.html new file mode 100644 index 0000000000..03b9b56f1f --- /dev/null +++ b/src/playground/without-layout/context-menu/context-menu-modes.component.html @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/src/playground/without-layout/context-menu/context-menu-modes.component.ts b/src/playground/without-layout/context-menu/context-menu-modes.component.ts new file mode 100644 index 0000000000..3d0bd6a5db --- /dev/null +++ b/src/playground/without-layout/context-menu/context-menu-modes.component.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { Component } from '@angular/core'; + + +@Component({ + selector: 'nb-context-menu-modes', + templateUrl: './context-menu-modes.component.html', + styles: [` + :host nb-layout-column { + height: 50vw; + } + + button { + margin-right: 1rem; + } + `], +}) +export class ContextMenuModesComponent { + items = [ + { title: 'Profile' }, + { title: 'Logout' }, + ]; +} diff --git a/src/playground/without-layout/context-menu/context-menu-noop.component.html b/src/playground/without-layout/context-menu/context-menu-noop.component.html new file mode 100644 index 0000000000..3459b08ff1 --- /dev/null +++ b/src/playground/without-layout/context-menu/context-menu-noop.component.html @@ -0,0 +1,10 @@ + + + + +
+ + +
+
+
diff --git a/src/playground/without-layout/context-menu/context-menu-noop.component.ts b/src/playground/without-layout/context-menu/context-menu-noop.component.ts new file mode 100644 index 0000000000..9081e7366b --- /dev/null +++ b/src/playground/without-layout/context-menu/context-menu-noop.component.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { Component, ViewChild } from '@angular/core'; +import { NbContextMenuDirective } from '@nebular/theme'; + + +@Component({ + selector: 'nb-context-menu-noop', + templateUrl: './context-menu-noop.component.html', + styles: [` + :host nb-layout-column { + height: 50vw; + } + .menu-target { + margin-bottom: 7rem; + } + button { + margin-right: 1rem; + margin-top: 1rem; + } + `], +}) +export class ContextMenuNoopComponent { + @ViewChild(NbContextMenuDirective) contextMenu: NbContextMenuDirective; + + items = [ + { title: 'Profile' }, + { title: 'Logout' }, + ]; + + open() { + this.contextMenu.show(); + } + + close() { + this.contextMenu.hide(); + } + +} diff --git a/src/playground/without-layout/context-menu/context-menu-routing.module.ts b/src/playground/without-layout/context-menu/context-menu-routing.module.ts index 8a5f62ca32..39cbde1cf6 100644 --- a/src/playground/without-layout/context-menu/context-menu-routing.module.ts +++ b/src/playground/without-layout/context-menu/context-menu-routing.module.ts @@ -9,6 +9,8 @@ import { RouterModule, Route} from '@angular/router'; import { ContextMenuClickComponent } from './context-menu-click.component'; import { ContextMenuShowcaseComponent } from './context-menu-showcase.component'; import { ContextMenuTestComponent } from './context-menu-test.component'; +import { ContextMenuModesComponent } from './context-menu-modes.component'; +import { ContextMenuNoopComponent } from './context-menu-noop.component'; const routes: Route[] = [ { @@ -23,6 +25,14 @@ const routes: Route[] = [ path: 'context-menu-test.component', component: ContextMenuTestComponent, }, + { + path: 'context-menu-modes.component', + component: ContextMenuModesComponent, + }, + { + path: 'context-menu-noop.component', + component: ContextMenuNoopComponent, + }, ]; @NgModule({ diff --git a/src/playground/without-layout/context-menu/context-menu.module.ts b/src/playground/without-layout/context-menu/context-menu.module.ts index 09c89fafb7..7446934fca 100644 --- a/src/playground/without-layout/context-menu/context-menu.module.ts +++ b/src/playground/without-layout/context-menu/context-menu.module.ts @@ -5,23 +5,35 @@ */ import { NgModule } from '@angular/core'; -import { NbCardModule, NbContextMenuModule, NbLayoutModule, NbMenuModule, NbUserModule } from '@nebular/theme'; +import { + NbButtonModule, + NbCardModule, + NbContextMenuModule, + NbLayoutModule, + NbMenuModule, + NbUserModule, +} from '@nebular/theme'; import { ContextMenuRoutingModule } from './context-menu-routing.module'; import { ContextMenuClickComponent } from './context-menu-click.component'; import { ContextMenuShowcaseComponent } from './context-menu-showcase.component'; import { ContextMenuTestComponent } from './context-menu-test.component'; +import { ContextMenuModesComponent } from './context-menu-modes.component'; +import { ContextMenuNoopComponent } from './context-menu-noop.component'; @NgModule({ declarations: [ ContextMenuClickComponent, ContextMenuShowcaseComponent, ContextMenuTestComponent, + ContextMenuModesComponent, + ContextMenuNoopComponent, ], imports: [ NbContextMenuModule, NbLayoutModule, NbUserModule, NbCardModule, + NbButtonModule, NbMenuModule.forRoot(), ContextMenuRoutingModule, ],