From d2e885e640b5240b1bee02efbbcd5be7f4e7a798 Mon Sep 17 00:00:00 2001 From: Leon Vay Date: Sun, 26 May 2019 14:21:20 +0300 Subject: [PATCH] feat(popover): added popover component (#120) --- package.json | 1 + packages/mosaic-dev/popover/module.ts | 158 ++++ packages/mosaic-dev/popover/styles.scss | 45 ++ packages/mosaic-dev/popover/template.html | 239 ++++++ .../core/overlay/overlay-position-map.ts | 45 +- .../styles/typography/_all-typography.scss | 2 + packages/mosaic/core/theming/_all-theme.scss | 2 + packages/mosaic/popover/README.md | 32 + packages/mosaic/popover/_popover-theme.scss | 107 +++ packages/mosaic/popover/index.ts | 1 + packages/mosaic/popover/popover-animations.ts | 26 + .../mosaic/popover/popover.component.html | 28 + packages/mosaic/popover/popover.component.ts | 758 ++++++++++++++++++ packages/mosaic/popover/popover.module.ts | 19 + packages/mosaic/popover/popover.scss | 194 +++++ packages/mosaic/popover/popover.spec.ts | 188 +++++ packages/mosaic/popover/public-api.ts | 3 + packages/mosaic/popover/tsconfig.build.json | 14 + 18 files changed, 1845 insertions(+), 17 deletions(-) create mode 100644 packages/mosaic-dev/popover/module.ts create mode 100644 packages/mosaic-dev/popover/styles.scss create mode 100644 packages/mosaic-dev/popover/template.html create mode 100644 packages/mosaic/popover/README.md create mode 100644 packages/mosaic/popover/_popover-theme.scss create mode 100644 packages/mosaic/popover/index.ts create mode 100644 packages/mosaic/popover/popover-animations.ts create mode 100644 packages/mosaic/popover/popover.component.html create mode 100644 packages/mosaic/popover/popover.component.ts create mode 100644 packages/mosaic/popover/popover.module.ts create mode 100644 packages/mosaic/popover/popover.scss create mode 100644 packages/mosaic/popover/popover.spec.ts create mode 100644 packages/mosaic/popover/public-api.ts create mode 100644 packages/mosaic/popover/tsconfig.build.json diff --git a/package.json b/package.json index b915f4d4e..d14b82767 100644 --- a/package.json +++ b/package.json @@ -174,6 +174,7 @@ "server-dev:modal": "npm run server-dev -- --env.component modal", "server-dev:navbar": "npm run server-dev -- --env.component navbar", "server-dev:panel": "npm run server-dev -- --env.component panel", + "server-dev:popover": "npm run server-dev -- --env.component popover", "server-dev:progress-bar": "npm run server-dev -- --env.component progress-bar", "server-dev:progress-spinner": "npm run server-dev -- --env.component progress-spinner", "server-dev:radio": "npm run server-dev -- --env.component radio", diff --git a/packages/mosaic-dev/popover/module.ts b/packages/mosaic-dev/popover/module.ts new file mode 100644 index 000000000..4daa815b1 --- /dev/null +++ b/packages/mosaic-dev/popover/module.ts @@ -0,0 +1,158 @@ +import { Component, NgModule, ViewEncapsulation } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { McButtonModule } from '@ptsecurity/mosaic/button'; +import { McPopoverModule } from '@ptsecurity/mosaic/popover'; +import { McIconModule } from '../../mosaic/icon/'; + + +/* tslint:disable:no-trailing-whitespace */ +@Component({ + selector: 'app', + styleUrls: ['./styles.css'], + encapsulation: ViewEncapsulation.None, + template: require('./template.html') +}) +export class DemoComponent { + private popoverActiveStage: number; + + private isPopoverVisibleLeft: boolean = false; + private isPopoverVisibleLeftTop: boolean = false; + private isPopoverVisibleLeftBottom: boolean = false; + private isPopoverVisibleBottom: boolean = false; + private isPopoverVisibleBottomRight: boolean = false; + private isPopoverVisibleBottomLeft: boolean = false; + private isPopoverVisibleRight: boolean = false; + private isPopoverVisibleRightTop: boolean = false; + private isPopoverVisibleRightBottom: boolean = false; + private isPopoverVisibleTop: boolean = false; + private isPopoverVisibleTopRight: boolean = false; + private isPopoverVisibleTopLeft: boolean = false; + + constructor() { + this.popoverActiveStage = 1; + } + + changeStep(direction: number) { + this.popoverActiveStage += direction; + } + + changePopoverVisibilityLeft() { + this.isPopoverVisibleLeft = !this.isPopoverVisibleLeft; + } + + changePopoverVisibilityLeftTop() { + this.isPopoverVisibleLeftTop = !this.isPopoverVisibleLeftTop; + } + + changePopoverVisibilityLeftBottom() { + this.isPopoverVisibleLeftBottom = !this.isPopoverVisibleLeftBottom; + } + + changePopoverVisibilityBottom() { + this.isPopoverVisibleBottom = !this.isPopoverVisibleBottom; + } + + changePopoverVisibilityBottomRight() { + this.isPopoverVisibleBottomRight = !this.isPopoverVisibleBottomRight; + } + + changePopoverVisibilityBottomLeft() { + this.isPopoverVisibleBottomLeft = !this.isPopoverVisibleBottomLeft; + } + + changePopoverVisibilityRight() { + this.isPopoverVisibleRight = !this.isPopoverVisibleRight; + } + + changePopoverVisibilityRightTop() { + this.isPopoverVisibleRightTop = !this.isPopoverVisibleRightTop; + } + + changePopoverVisibilityRightBottom() { + this.isPopoverVisibleRightBottom = !this.isPopoverVisibleRightBottom; + } + + changePopoverVisibilityTop() { + this.isPopoverVisibleTop = !this.isPopoverVisibleTop; + } + + changePopoverVisibilityTopRight() { + this.isPopoverVisibleTopRight = !this.isPopoverVisibleTopRight; + } + + changePopoverVisibilityTopLeft() { + this.isPopoverVisibleTopLeft = !this.isPopoverVisibleTopLeft; + } + + onPopoverVisibleChangeLeft(update: boolean) { + this.isPopoverVisibleLeft = update; + } + + onPopoverVisibleChangeLeftTop(update: boolean) { + this.isPopoverVisibleLeftTop = update; + } + + onPopoverVisibleChangeLeftBottom(update: boolean) { + this.isPopoverVisibleLeftBottom = update; + } + + onPopoverVisibleChangeBottom(update: boolean) { + this.isPopoverVisibleBottom = update; + } + + onPopoverVisibleChangeBottomRight(update: boolean) { + this.isPopoverVisibleBottomRight = update; + } + + onPopoverVisibleChangeBottomLeft(update: boolean) { + this.isPopoverVisibleBottomLeft = update; + } + + onPopoverVisibleChangeRight(update: boolean) { + this.isPopoverVisibleRight = update; + } + + onPopoverVisibleChangeRightTop(update: boolean) { + this.isPopoverVisibleRightTop = update; + } + + onPopoverVisibleChangeRightBottom(update: boolean) { + this.isPopoverVisibleRightBottom = update; + } + + onPopoverVisibleChangeTop(update: boolean) { + this.isPopoverVisibleTop = update; + } + + onPopoverVisibleChangeTopRight(update: boolean) { + this.isPopoverVisibleTopRight = update; + } + + onPopoverVisibleChangeTopLeft(update: boolean) { + this.isPopoverVisibleTopLeft = update; + } +} + +@NgModule({ + declarations: [ + DemoComponent + ], + imports: [ + BrowserModule, + BrowserAnimationsModule, + McPopoverModule, + McButtonModule, + McIconModule + ], + bootstrap: [ + DemoComponent + ] +}) +export class DemoModule { +} + +platformBrowserDynamic() + .bootstrapModule(DemoModule) + .catch((error) => console.error(error)); // tslint:disable-line diff --git a/packages/mosaic-dev/popover/styles.scss b/packages/mosaic-dev/popover/styles.scss new file mode 100644 index 000000000..cc06eb38c --- /dev/null +++ b/packages/mosaic-dev/popover/styles.scss @@ -0,0 +1,45 @@ +@import '~@ptsecurity/mosaic-icons/dist/styles/mc-icons'; + +@import '../../mosaic/core/visual/prebuilt/default-visual'; + +@import '../../mosaic/core/theming/prebuilt/default-theme'; +//@import '../../lib/core/theming/prebuilt/dark-theme'; + +@include mosaic-visual(); + +body, html { + width: 100%; + height: 100%; + margin: 0; +} + +app { + height: 100%; + width: 100%; + display: flex; + flex: 1 1 100%; + flex-flow: column; + justify-content: center; + align-items: stretch; + padding: 0 10%; +} + +.popover-485 { + width: 485px; +} + + +button.step { + display: inline-block; + width: 30%; + min-width: 100px; +} + +.trigger-button { + width: 130px; + margin: 15px; +} + +button.with-margin { + margin: 0 5%; +} diff --git a/packages/mosaic-dev/popover/template.html b/packages/mosaic-dev/popover/template.html new file mode 100644 index 000000000..fa2de398c --- /dev/null +++ b/packages/mosaic-dev/popover/template.html @@ -0,0 +1,239 @@ + + + + В западной традиции рыбой выступает фрагмент латинского текста из философского трактата Цицерона «О пределах добра и зла», написанного в 45 году до нашей эры. Впервые этот текст был применен для набора шрифтовых образцов неизвестным печатником еще в XVI веке. + + + Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. + + + Сегодня этот текст используют практически все дизайнеры, набирающие рыбу латиницей. Абзац считается каноническим во всех справочниках по типографике и предлагается к использованию в статьях, посвященных изготовлению макета верстки при отсутствии финальных текстов. В руководствах по работе с фирменным стилем крупных международных компаний именно с этих слов начинаются образцы верстки. Существуют даже издания с названием Lorem ipsum. + + + + + +
+ + +
+
+ +
+ + + + + +
+ +
+
+ + + + rightBottom + + +
+ +
+ + + +
+
+ +
+ + + + + +
diff --git a/packages/mosaic/core/overlay/overlay-position-map.ts b/packages/mosaic/core/overlay/overlay-position-map.ts index a44b35af4..d43275b67 100644 --- a/packages/mosaic/core/overlay/overlay-position-map.ts +++ b/packages/mosaic/core/overlay/overlay-position-map.ts @@ -12,25 +12,19 @@ export const POSITION_MAP: { [key: string]: ConnectionPositionPair } = { originX : 'center', originY : 'top', overlayX: 'center', - overlayY: 'bottom', - offsetX : undefined, - offsetY : undefined + overlayY: 'bottom' }, topLeft : { originX : 'start', originY : 'top', overlayX: 'start', - overlayY: 'bottom', - offsetX : undefined, - offsetY : undefined + overlayY: 'bottom' }, topRight : { originX : 'end', originY : 'top', overlayX: 'end', - overlayY: 'bottom', - offsetX : undefined, - offsetY : undefined + overlayY: 'bottom' }, right : { originX : 'end', @@ -42,17 +36,13 @@ export const POSITION_MAP: { [key: string]: ConnectionPositionPair } = { originX : 'end', originY : 'top', overlayX: 'start', - overlayY: 'top', - offsetX : undefined, - offsetY : undefined + overlayY: 'top' }, rightBottom : { originX : 'end', originY : 'bottom', overlayX: 'start', - overlayY: 'bottom', - offsetX : undefined, - offsetY : undefined + overlayY: 'bottom' }, bottom : { originX : 'center', @@ -98,10 +88,31 @@ export const POSITION_MAP: { [key: string]: ConnectionPositionPair } = { } }; -export const DEFAULT_4_POSITIONS = _objectValues([ +export const DEFAULT_4_POSITIONS = objectValues([ POSITION_MAP.top, POSITION_MAP.right, POSITION_MAP.bottom, POSITION_MAP.left ]); +export const EXTENDED_OVERLAY_POSITIONS = objectValues([ + POSITION_MAP.top, POSITION_MAP.topLeft, POSITION_MAP.topRight, POSITION_MAP.right, POSITION_MAP.rightTop, + POSITION_MAP.rightBottom, POSITION_MAP.bottom, POSITION_MAP.bottomLeft, POSITION_MAP.bottomRight, + POSITION_MAP.left, POSITION_MAP.leftTop, POSITION_MAP.leftBottom +]); + +export const POSITION_TO_CSS_MAP: {[key: string]: string} = { + top: 'top', + topLeft: 'top-left', + topRight: 'top-right', + right: 'right', + rightTop: 'right-top', + rightBottom: 'right-bottom', + left: 'left', + leftTop: 'left-top', + leftBottom: 'left-bottom', + bottom: 'bottom', + bottomLeft: 'bottom-left', + bottomRight: 'bottom-right' +}; + function arrayMap(array: T[], iteratee: (item: T, index: number, arr: T[]) => S): S[] { let index = -1; const length = array == null ? 0 : array.length; @@ -118,6 +129,6 @@ function baseValues(object: { [key: string]: T } | T[], props: string[]): T[] return object[ key ]; }); } -function _objectValues(object: { [key: string]: T } | T[]): T[] { +function objectValues(object: { [key: string]: T } | T[]): T[] { return object == null ? [] : baseValues(object, Object.keys(object)); } diff --git a/packages/mosaic/core/styles/typography/_all-typography.scss b/packages/mosaic/core/styles/typography/_all-typography.scss index bf56593f5..33a3f47fa 100644 --- a/packages/mosaic/core/styles/typography/_all-typography.scss +++ b/packages/mosaic/core/styles/typography/_all-typography.scss @@ -13,6 +13,7 @@ @import '../../../list/list-theme'; @import '../../../modal/modal-theme'; @import '../../../navbar/navbar-theme'; +@import '../../../popover/popover-theme'; @import '../../../radio/radio-theme'; @import '../../../select/select-theme'; @import '../../../sidepanel/sidepanel-theme'; @@ -49,6 +50,7 @@ @include mc-modal-typography($config); @include mc-navbar-typography($config); @include mc-option-typography($config); + @include mc-popover-typography($config); @include mc-radio-typography($config); @include mc-select-typography($config); @include mc-sidepanel-typography($config); diff --git a/packages/mosaic/core/theming/_all-theme.scss b/packages/mosaic/core/theming/_all-theme.scss index 3c298e199..0a61572d1 100644 --- a/packages/mosaic/core/theming/_all-theme.scss +++ b/packages/mosaic/core/theming/_all-theme.scss @@ -16,6 +16,7 @@ @import '../../list/list-theme'; @import '../../modal/modal-theme'; @import '../../navbar/navbar-theme'; +@import '../../popover/popover-theme'; @import '../../progress-bar/progress-bar-theme'; @import '../../progress-spinner/progress-spinner-theme'; @import '../../radio/radio-theme'; @@ -56,6 +57,7 @@ @include mc-navbar-theme($theme); @include mc-option-theme($theme); @include mc-panel-theme($theme); + @include mc-popover-theme($theme); @include mc-progress-bar-theme($theme); @include mc-progress-spinner-theme($theme); @include mc-radio-theme($theme); diff --git a/packages/mosaic/popover/README.md b/packages/mosaic/popover/README.md new file mode 100644 index 000000000..194c7909c --- /dev/null +++ b/packages/mosaic/popover/README.md @@ -0,0 +1,32 @@ +# Popover component + +## API + +| Property | Description | Type | Default | +|------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------|---------| +| mcPopoverPlacement | Место для показа относительно элемента, к которому он привязан. Возможные значения: top, bottom, left, right | string | bottom | +| mcPopoverTrigger | Триггер для показа Возможные значения: hover, manual, click, focus | string | hover | +| mcPopoverVisible | Ручное управление показом, используется только при mcPopoverTrigger="manual" | boolean | false | +| mcPopoverHeader | Содержимое шапки Обрати внимание: если используешь строку, то используй аккуратно, желательно предварительно сделать escape строки, чтобы избежать потенциальной XSS уязвимости. | string | ng-template | – | +| mcPopoverContent | Содержимое компонента Обрати внимание: если используешь строку, то используй аккуратно, желательно предварительно сделать escape строки, чтобы избежать потенциальной XSS уязвимости. | string | ng-template | – | +| mcPopoverFooter | Содержимое подвала Обрати внимание: если используешь строку, то используй аккуратно, желательно предварительно сделать escape строки, чтобы избежать потенциальной XSS уязвимости. | string | ng-template | – | +| mcPopoverDisabled | Флаг для запрета показа | boolean | false | +| mcPopoverClass | Добавление своих классов | string | string[] | – | +| mcPopoverVisibleChange | Callback на изменение видимости компонента | EventEmitter | – | + +## Example + +```html + + Э́йяфьядлайё̀кюдль — шестой по величине ледник Исландии. Расположен на юге Исландии в 125 км к востоку от Рейкьявика. Под этим ледником находится одноимённый вулкан конической формы. + + + +``` \ No newline at end of file diff --git a/packages/mosaic/popover/_popover-theme.scss b/packages/mosaic/popover/_popover-theme.scss new file mode 100644 index 000000000..2c36d5e7f --- /dev/null +++ b/packages/mosaic/popover/_popover-theme.scss @@ -0,0 +1,107 @@ +@import '../core/theming/theming'; +@import '../core/styles/typography/typography-utils'; + +@import '../core/styles/common/animation'; + + +@mixin mc-popover-theme($theme) { + $background: map-get($theme, background); + $foreground: map-get($theme, foreground); + + $is-dark: map-get($theme, is-dark); + + $primary-background-color: map-get($background, background); + $secondary-background-color: if($is-dark, map-get($background, background), map-get($background, button-bg)); + $border-color: if($is-dark, $primary-background-color, map-get($foreground, disabled)); + + .mc-popover__container { + color: map-get($foreground, text); + box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2); + background-color: $primary-background-color; + border-color: $border-color; + } + + .mc-popover__header { + border-bottom-color: map-get($foreground, divider); + } + + .mc-popover__header, .mc-popover__content { + background-color: $primary-background-color; + } + + .mc-popover__footer { + background-color: $secondary-background-color; + border-top-color: if($is-dark, map-get($foreground, divider), $secondary-background-color); + } + + .mc-popover .mc-popover__arrow { + border-color: $border-color; + } + + .mc-popover_placement-top .mc-popover__arrow, + .mc-popover_placement-top-left .mc-popover__arrow, + .mc-popover_placement-top-right .mc-popover__arrow{ + background-color: $primary-background-color; + border-top-color: $primary-background-color; + border-left-color: $primary-background-color; + } + + .mc-popover_placement-top .mc-popover__arrow.mc-popover__arrow_with-footer, + .mc-popover_placement-top-left .mc-popover__arrow.mc-popover__arrow_with-footer, + .mc-popover_placement-top-right .mc-popover__arrow.mc-popover__arrow_with-footer { + background-color: $secondary-background-color; + border-top-color: $secondary-background-color; + border-left-color: $secondary-background-color; + } + + .mc-popover_placement-right .mc-popover__arrow, + .mc-popover_placement-right-top .mc-popover__arrow, + .mc-popover_placement-right-bottom .mc-popover__arrow{ + background-color: $primary-background-color; + border-top-color: $primary-background-color; + border-right-color: $primary-background-color; + } + + .mc-popover_placement-right-bottom .mc-popover__arrow.mc-popover__arrow_with-footer { + background-color: $secondary-background-color; + border-top-color: $secondary-background-color; + border-right-color: $secondary-background-color; + } + + .mc-popover_placement-left .mc-popover__arrow, + .mc-popover_placement-left-top .mc-popover__arrow, + .mc-popover_placement-left-bottom .mc-popover__arrow { + background-color: $primary-background-color; + border-bottom-color: $primary-background-color; + border-left-color: $primary-background-color; + } + + .mc-popover_placement-left-bottom .mc-popover__arrow.mc-popover__arrow_with-footer { + background-color: $secondary-background-color; + border-bottom-color: $secondary-background-color; + border-left-color: $secondary-background-color; + } + + .mc-popover_placement-bottom .mc-popover__arrow, + .mc-popover_placement-bottom-left .mc-popover__arrow, + .mc-popover_placement-bottom-right .mc-popover__arrow, + .mc-popover_placement-bottom .mc-popover__arrow.mc-popover__arrow_with-footer, + .mc-popover_placement-bottom-left .mc-popover__arrow.mc-popover__arrow_with-footer, + .mc-popover_placement-bottom-right .mc-popover__arrow.mc-popover__arrow_with-footer{ + background-color: $primary-background-color; + border-right-color: $primary-background-color; + border-bottom-color: $primary-background-color; + } +} + +@mixin mc-popover-typography($config) { + .mc-popover__content, + .mc-popover__footer { + @include mc-typography-level-to-styles($config, body); + } + + .mc-popover__header { + @include mc-typography-level-to-styles($config, subheading); + } +} + diff --git a/packages/mosaic/popover/index.ts b/packages/mosaic/popover/index.ts new file mode 100644 index 000000000..7e1a213e3 --- /dev/null +++ b/packages/mosaic/popover/index.ts @@ -0,0 +1 @@ +export * from './public-api'; diff --git a/packages/mosaic/popover/popover-animations.ts b/packages/mosaic/popover/popover-animations.ts new file mode 100644 index 000000000..1d775048a --- /dev/null +++ b/packages/mosaic/popover/popover-animations.ts @@ -0,0 +1,26 @@ +import { + animate, + AnimationTriggerMetadata, + state, + style, + transition, + trigger +} from '@angular/animations'; + + +export const mcPopoverAnimations: { + readonly popoverState: AnimationTriggerMetadata; +} = { + /** Animation that transitions a tooltip in and out. */ + popoverState: trigger('state', [ + state('initial', style({ + opacity: 0, + transform: 'scale(1, 0.8)' + })), + transition('* => visible', animate('120ms cubic-bezier(0, 0, 0.2, 1)', style({ + opacity: 1, + transform: 'scale(1, 1)' + }))), + transition('* => hidden', animate('100ms linear', style({ opacity: 0 }))) + ]) +}; diff --git a/packages/mosaic/popover/popover.component.html b/packages/mosaic/popover/popover.component.html new file mode 100644 index 000000000..10fdc77a0 --- /dev/null +++ b/packages/mosaic/popover/popover.component.html @@ -0,0 +1,28 @@ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
diff --git a/packages/mosaic/popover/popover.component.ts b/packages/mosaic/popover/popover.component.ts new file mode 100644 index 000000000..92474bd3e --- /dev/null +++ b/packages/mosaic/popover/popover.component.ts @@ -0,0 +1,758 @@ +import { AnimationEvent } from '@angular/animations'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Directive, + ElementRef, + EventEmitter, + Inject, + InjectionToken, + Input, + NgZone, + OnDestroy, + OnInit, + Optional, + Output, + TemplateRef, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { Directionality } from '@ptsecurity/cdk/bidi'; +import { coerceBooleanProperty } from '@ptsecurity/cdk/coercion'; +import { ESCAPE } from '@ptsecurity/cdk/keycodes'; +import { + ConnectedOverlayPositionChange, + ConnectionPositionPair, + Overlay, + OverlayRef, + ScrollDispatcher, + IScrollStrategy, + FlexibleConnectedPositionStrategy, + IOverlayConnectionPosition, + IOriginConnectionPosition, + HorizontalConnectionPos, + VerticalConnectionPos +} from '@ptsecurity/cdk/overlay'; +import { ComponentPortal } from '@ptsecurity/cdk/portal'; +import { + EXTENDED_OVERLAY_POSITIONS, + POSITION_MAP, + POSITION_TO_CSS_MAP +} from '@ptsecurity/mosaic/core'; +import { BehaviorSubject, Observable, Subject } from 'rxjs'; +import { distinctUntilChanged, takeUntil } from 'rxjs/operators'; + +import { mcPopoverAnimations } from './popover-animations'; + + +export type PopoverVisibility = 'initial' | 'visible' | 'hidden'; + +@Component({ + selector: 'mc-popover', + templateUrl: './popover.component.html', + preserveWhitespaces: false, + styleUrls: ['./popover.css'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [mcPopoverAnimations.popoverState], + host: { + '[class]': 'getCssClassesList', + '(body:click)': 'handleBodyInteraction($event)' + } +}) +export class McPopoverComponent { + positions: ConnectionPositionPair[] = [ ...EXTENDED_OVERLAY_POSITIONS ]; + availablePositions: any; + popoverVisibility: PopoverVisibility = 'initial'; + closeOnInteraction: boolean = false; + mcContent: string | TemplateRef; + mcHeader: string | TemplateRef; + mcFooter: string | TemplateRef; + + @Output('mcPopoverVisibleChange') mcVisibleChange: EventEmitter = new EventEmitter(); + + get mcTrigger(): string { + return this._mcTrigger; + } + set mcTrigger(value: string) { + this._mcTrigger = value; + } + private _mcTrigger: string = 'hover'; + + get mcPlacement(): string { + return this._mcPlacement; + } + set mcPlacement(value: string) { + if (value !== this._mcPlacement) { + this._mcPlacement = value; + this.positions.unshift(POSITION_MAP[ this.mcPlacement ]); + } else if (!value) { + this._mcPlacement = 'top'; + } + } + private _mcPlacement: string = 'top'; + + get mcPopoverSize(): string { + return this.popoverSize; + } + set mcPopoverSize(value: string) { + if (value !== this.popoverSize) { + this.popoverSize = value; + } else if (!value) { + this.popoverSize = 'normal'; + } + } + private popoverSize: string; + + get mcVisible(): boolean { + return this._mcVisible.value; + } + set mcVisible(value: boolean) { + const visible = coerceBooleanProperty(value); + + if (this._mcVisible.value !== visible) { + this._mcVisible.next(visible); + this.mcVisibleChange.emit(visible); + } + } + private _mcVisible: BehaviorSubject = new BehaviorSubject(false); + + get classList() { + return this._classList.join(' '); + } + set classList(value: string | string[]) { + let list: string[] = []; + + if (Array.isArray(value)) { + list = value; + } else { + list.push(value); + } + + this._classList = list; + } + private _classList: string[] = []; + + get getCssClassesList(): string { + return `${this.classList} mc-popover-${this.mcPopoverSize} mc-popover_placement-${this.getPlacementClass}`; + } + + get getPlacementClass(): string { + return POSITION_TO_CSS_MAP[this.mcPlacement]; + } + + /** Subject for notifying that the popover has been hidden from the view */ + private readonly onHideSubject: Subject = new Subject(); + + constructor(public changeDetectorRef: ChangeDetectorRef, public componentElementRef: ElementRef) { + this.availablePositions = POSITION_MAP; + } + + show(): void { + if (this.isNonEmptyContent()) { + this.closeOnInteraction = true; + this.popoverVisibility = 'visible'; + // Mark for check so if any parent component has set the + // ChangeDetectionStrategy to OnPush it will be checked anyways + this.markForCheck(); + } + } + + hide(): void { + this.popoverVisibility = 'hidden'; + this.mcVisibleChange.emit(false); + + // Mark for check so if any parent component has set the + // ChangeDetectionStrategy to OnPush it will be checked anyways + this.markForCheck(); + } + + isNonEmptyContent(): boolean { + return !!this.mcContent && (this.isTemplateRef(this.mcContent) || this.isNonEmptyString(this.mcContent)); + } + + /** Returns an observable that notifies when the popover has been hidden from view. */ + afterHidden(): Observable { + return this.onHideSubject.asObservable(); + } + + isVisible(): boolean { + return this.popoverVisibility === 'visible'; + } + + markForCheck(): void { + this.changeDetectorRef.markForCheck(); + } + + isTemplateRef(value: any): boolean { + return value instanceof TemplateRef; + } + + isNonEmptyString(value: any): boolean { + return typeof value === 'string' && value !== ''; + } + + handleBodyInteraction(e): void { + if (this.closeOnInteraction && !this.componentElementRef.nativeElement.contains(e.target)) { + this.hide(); + } + } + + animationStart() { + this.closeOnInteraction = false; + } + + animationDone(event: AnimationEvent): void { + const toState = event.toState as PopoverVisibility; + + if (toState === 'hidden' && !this.isVisible()) { + this.onHideSubject.next(); + } + + if (toState === 'visible' || toState === 'hidden') { + this.closeOnInteraction = true; + } + } + + ngOnDestroy() { + this.onHideSubject.complete(); + } +} + +export const MC_POPOVER_SCROLL_STRATEGY = + new InjectionToken<() => IScrollStrategy>('mc-popover-scroll-strategy'); + +/** @docs-private */ +export function mcPopoverScrollStrategyFactory(overlay: Overlay): () => IScrollStrategy { + return () => overlay.scrollStrategies.reposition({scrollThrottle: 20}); +} + +/** @docs-private */ +export const MC_POPOVER_SCROLL_STRATEGY_FACTORY_PROVIDER = { + provide: MC_POPOVER_SCROLL_STRATEGY, + deps: [Overlay], + useFactory: mcPopoverScrollStrategyFactory +}; + +/** Creates an error to be thrown if the user supplied an invalid popover position. */ +export function getMcPopoverInvalidPositionError(position: string) { + return Error(`McPopover position "${position}" is invalid.`); +} + +const VIEWPORT_MARGIN: number = 8; +/** @docs-private + * Minimal width of anchor element should be equal or greater than popover arrow width plus arrow offset right/left + * MIN_ANCHOR_ELEMENT_WIDTH used for positioning update inside handlePositionUpdate() + */ +const MIN_ANCHOR_ELEMENT_WIDTH: number = 40; + +@Directive({ + selector: '[mcPopover]', + exportAs: 'mcPopover', + host: { + '(keydown)': 'handleKeydown($event)', + '(touchend)': 'handleTouchend()', + '[class.mc-popover_open]': 'isOpen' + } +}) +export class McPopover implements OnInit, OnDestroy { + isPopoverOpen: boolean = false; + isDynamicPopover = false; + overlayRef: OverlayRef | null; + portal: ComponentPortal; + availablePositions: any; + popover: McPopoverComponent | null; + + @Output('mcPopoverVisibleChange') mcVisibleChange = new EventEmitter(); + + @Input('mcPopoverHeader') + get mcHeader(): string | TemplateRef { + return this._mcHeader; + } + set mcHeader(value: string | TemplateRef) { + this._mcHeader = value; + this.updateCompValue('mcHeader', value); + + if (this.isPopoverOpen) { + this.updatePosition(true); + } + } + private _mcHeader: string | TemplateRef; + + @Input('mcPopoverContent') + get mcContent(): string | TemplateRef { + return this._mcContent; + } + set mcContent(value: string | TemplateRef) { + this._mcContent = value; + this.updateCompValue('mcContent', value); + + if (this.isPopoverOpen) { + this.updatePosition(true); + } + } + private _mcContent: string | TemplateRef; + + @Input('mcPopoverFooter') + get mcFooter(): string | TemplateRef { + return this._mcFooter; + } + set mcFooter(value: string | TemplateRef) { + this._mcFooter = value; + this.updateCompValue('mcFooter', value); + + if (this.isPopoverOpen) { + this.updatePosition(true); + } + } + private _mcFooter: string | TemplateRef; + + private $unsubscribe = new Subject(); + + @Input('mcPopoverDisabled') + get disabled(): boolean { + return this._disabled; + } + set disabled(value) { + this._disabled = coerceBooleanProperty(value); + } + private _disabled: boolean = false; + + @Input('mcPopoverMouseEnterDelay') + get mcMouseEnterDelay(): number { + return this._mcMouseEnterDelay; + } + set mcMouseEnterDelay(value: number) { + this._mcMouseEnterDelay = value; + this.updateCompValue('mcMouseEnterDelay', value); + } + private _mcMouseEnterDelay: number; + + @Input('mcPopoverMouseLeaveDelay') + get mcMouseLeaveDelay(): number { + return this._mcMouseLeaveDelay; + } + set mcMouseLeaveDelay(value: number) { + this._mcMouseLeaveDelay = value; + this.updateCompValue('mcMouseLeaveDelay', value); + } + private _mcMouseLeaveDelay: number; + + @Input('mcPopoverTrigger') + get mcTrigger(): string { + return this._mcTrigger; + } + set mcTrigger(value: string) { + if (value) { + this._mcTrigger = value; + this.updateCompValue('mcTrigger', value); + } else { + this._mcTrigger = 'hover'; + } + } + private _mcTrigger: string = 'hover'; + + @Input('mcPopoverSize') + get mcPopoverSize(): string { + return this.popoverSize; + } + set mcPopoverSize(value: string) { + if (value && (value === 'small' || value === 'normal' || value === 'large')) { + this.popoverSize = value; + this.updateCompValue('mcPopoverSize', value); + } else { + this.popoverSize = 'normal'; + } + } + private popoverSize: string = 'normal'; + + @Input('mcPopoverPlacement') + get mcPlacement(): string { + return this._mcPlacement; + } + set mcPlacement(value: string) { + if (value) { + this._mcPlacement = value; + this.updateCompValue('mcPlacement', value); + } else { + this._mcPlacement = 'top'; + } + } + private _mcPlacement: string = 'top'; + + @Input('mcPopoverClass') + get classList() { + return this._classList; + } + set classList(value: string | string[]) { + this._classList = value; + this.updateCompValue('classList', this._classList); + } + private _classList: string | string[]; + + @Input('mcPopoverVisible') + get mcVisible(): boolean { + return this._mcVisible; + } + set mcVisible(externalValue: boolean) { + const value = coerceBooleanProperty(externalValue); + this._mcVisible = value; + this.updateCompValue('mcVisible', value); + + if (value) { + this.show(); + } else { + this.hide(); + } + } + private _mcVisible: boolean; + + get isOpen(): boolean { + return this.isPopoverOpen; + } + + private manualListeners = new Map(); + private readonly destroyed = new Subject(); + + constructor( + private overlay: Overlay, + private elementRef: ElementRef, + private ngZone: NgZone, + private scrollDispatcher: ScrollDispatcher, + private hostView: ViewContainerRef, + @Inject(MC_POPOVER_SCROLL_STRATEGY) private scrollStrategy, + @Optional() private direction: Directionality) { + this.availablePositions = POSITION_MAP; + } + + /** Create the overlay config and position strategy */ + createOverlay(): OverlayRef { + if (this.overlayRef) { + return this.overlayRef; + } + + // Create connected position strategy that listens for scroll events to reposition. + const strategy = this.overlay.position() + .flexibleConnectedTo(this.elementRef) + .withTransformOriginOn('.mc-popover') + .withFlexibleDimensions(false) + .withViewportMargin(VIEWPORT_MARGIN) + .withPositions([ ...EXTENDED_OVERLAY_POSITIONS ]); + + const scrollableAncestors = this.scrollDispatcher + .getAncestorScrollContainers(this.elementRef); + + strategy.withScrollableContainers(scrollableAncestors); + + strategy.positionChanges.pipe(takeUntil(this.destroyed)).subscribe((change) => { + if (this.popover) { + this.onPositionChange(change); + if (change.scrollableViewProperties.isOverlayClipped && this.popover.mcVisible) { + // After position changes occur and the overlay is clipped by + // a parent scrollable then close the popover. + this.ngZone.run(() => this.hide()); + } + } + }); + + this.overlayRef = this.overlay.create({ + direction: this.direction, + positionStrategy: strategy, + panelClass: 'mc-popover__panel', + scrollStrategy: this.scrollStrategy() + }); + + this.updatePosition(); + + this.overlayRef.detachments() + .pipe(takeUntil(this.destroyed)) + .subscribe(() => this.detach()); + + return this.overlayRef; + } + + detach() { + if (this.overlayRef && this.overlayRef.hasAttached() && this.popover) { + this.overlayRef.detach(); + this.popover = null; + } + } + + onPositionChange($event: ConnectedOverlayPositionChange): void { + let updatedPlacement = this.mcPlacement; + Object.keys(this.availablePositions).some((key) => { + if ($event.connectionPair.originX === this.availablePositions[key].originX && + $event.connectionPair.originY === this.availablePositions[key].originY && + $event.connectionPair.overlayX === this.availablePositions[key].overlayX && + $event.connectionPair.overlayY === this.availablePositions[key].overlayY) { + updatedPlacement = key; + + return true; + } + + return false; + }); + this.updateCompValue('mcPlacement', updatedPlacement); + + if (this.popover) { + this.updateCompValue('classList', this.classList); + this.popover.markForCheck(); + } + + this.handlePositionUpdate(); + } + + handlePositionUpdate() { + if (!this.overlayRef) { + this.overlayRef = this.createOverlay(); + } + + const verticalOffset = this.hostView.element.nativeElement.clientHeight / 2; // tslint:disable-line + const anchorElementWidth = this.hostView.element.nativeElement.clientWidth; // tslint:disable-line + + if (this.mcPlacement === 'rightTop' || this.mcPlacement === 'leftTop') { + const currentContainer = this.overlayRef.overlayElement.style.top || '0px'; + this.overlayRef.overlayElement.style.top = + `${parseInt(currentContainer.split('px')[0], 10) + verticalOffset - 20}px`; // tslint:disable-line + } + + if (this.mcPlacement === 'rightBottom' || this.mcPlacement === 'leftBottom') { + const currentContainer = this.overlayRef.overlayElement.style.bottom || '0px'; + this.overlayRef.overlayElement.style.bottom = + `${parseInt(currentContainer.split('px')[0], 10) + verticalOffset - 22}px`; // tslint:disable-line + } + + if ((this.mcPlacement === 'topRight' || this.mcPlacement === 'bottomRight') && + anchorElementWidth < MIN_ANCHOR_ELEMENT_WIDTH) { + const currentContainer = this.overlayRef.overlayElement.style.right || '0px'; + this.overlayRef.overlayElement.style.right = + `${parseInt(currentContainer.split('px')[0], 10) - 18}px`; // tslint:disable-line + } + + if ((this.mcPlacement === 'topLeft' || this.mcPlacement === 'bottomLeft') && + anchorElementWidth < MIN_ANCHOR_ELEMENT_WIDTH) { + const currentContainer = this.overlayRef.overlayElement.style.left || '0px'; + this.overlayRef.overlayElement.style.left = + `${parseInt(currentContainer.split('px')[0], 10) - 20}px`; // tslint:disable-line + } + } + + // tslint:disable-next-line:no-any + updateCompValue(key: string, value: any): void { + if (this.isDynamicPopover && value) { + if (this.popover) { + this.popover[key] = value; + } + } + } + + ngOnInit(): void { + this.initElementRefListeners(); + } + + ngOnDestroy(): void { + if (this.overlayRef) { + this.overlayRef.dispose(); + } + this.manualListeners.forEach((listener, event) => + this.elementRef.nativeElement.removeEventListener(event, listener)); + this.manualListeners.clear(); + + this.$unsubscribe.next(); + this.$unsubscribe.complete(); + } + + handleKeydown(e: KeyboardEvent) { + if (this.isOpen && e.keyCode === ESCAPE) { // tslint:disable-line + this.hide(); + } + } + + handleTouchend() { + this.hide(); + } + + initElementRefListeners() { + if (this.mcTrigger === 'hover') { + + this.manualListeners + .set('mouseenter', () => this.show()) + .set('mouseleave', () => this.hide()) + .forEach((listener, event) => this.elementRef.nativeElement.addEventListener(event, listener)); + } + + if (this.mcTrigger === 'focus') { + + this.manualListeners + .set('focus', () => this.show()) + .set('blur', () => this.hide()) + .forEach((listener, event) => this.elementRef.nativeElement.addEventListener(event, listener)); + } + } + + show(): void { + if (!this.disabled) { + if (!this.popover) { + const overlayRef = this.createOverlay(); + this.detach(); + + this.portal = this.portal || new ComponentPortal(McPopoverComponent, this.hostView); + + this.popover = overlayRef.attach(this.portal).instance; + this.isDynamicPopover = true; + const properties = [ + 'mcPlacement', + 'mcPopoverSize', + 'mcTrigger', + 'mcMouseEnterDelay', + 'mcMouseLeaveDelay', + 'classList', + 'mcVisible', + 'mcHeader', + 'mcContent', + 'mcFooter' + ]; + + properties.forEach((property) => this.updateCompValue(property, this[ property ])); + this.popover.mcVisibleChange.pipe(takeUntil(this.$unsubscribe), distinctUntilChanged()) + .subscribe((data) => { + this.mcVisible = data; + this.mcVisibleChange.emit(data); + this.isPopoverOpen = data; + }); + this.popover.afterHidden() + .pipe(takeUntil(this.destroyed)) + .subscribe(() => this.detach()); + } + this.updatePosition(); + this.popover.show(); + } + } + + hide(): void { + if (this.popover) { + this.popover.hide(); + } + } + + /** Updates the position of the current popover. */ + updatePosition(reapplyPosition: boolean = false) { + if (!this.overlayRef) { + this.overlayRef = this.createOverlay(); + } + const position = + this.overlayRef.getConfig().positionStrategy as FlexibleConnectedPositionStrategy; + const origin = this.getOrigin(); + const overlay = this.getOverlayPosition(); + + position.withPositions([ + {...origin.main, ...overlay.main}, + {...origin.fallback, ...overlay.fallback} + ]); + + // + // FIXME: Необходимо в некоторых моментах форсировать позиционировать только после рендеринга всего контента + // + if (reapplyPosition) { + setTimeout(() => { + position.reapplyLastPosition(); + }); + } + } + + /** + * Returns the origin position and a fallback position based on the user's position preference. + * The fallback position is the inverse of the origin (e.g. `'below' -> 'above'`). + */ + getOrigin(): {main: IOriginConnectionPosition; fallback: IOriginConnectionPosition} { + let originPosition: IOriginConnectionPosition; + const originXPosition = this.getOriginXaxis(); + const originYPosition = this.getOriginYaxis(); + originPosition = {originX: originXPosition, originY: originYPosition}; + + const {x, y} = this.invertPosition(originPosition.originX, originPosition.originY); + + return { + main: originPosition, + fallback: {originX: x, originY: y} + }; + } + + getOriginXaxis(): HorizontalConnectionPos { + const position = this.mcPlacement; + let origX: HorizontalConnectionPos; + const isLtr = !this.direction || this.direction.value === 'ltr'; + if (position === 'top' || position === 'bottom') { + origX = 'center'; + } else if (position.toLowerCase().includes('right') && !isLtr || + position.toLowerCase().includes('left') && isLtr) { + origX = 'start'; + } else if (position.toLowerCase().includes('right') && isLtr || + position.toLowerCase().includes('left') && !isLtr) { + origX = 'end'; + } else { + throw getMcPopoverInvalidPositionError(position); + } + + return origX; + } + + getOriginYaxis(): VerticalConnectionPos { + const position = this.mcPlacement; + let origY: VerticalConnectionPos; + if (position === 'right' || position === 'left') { + origY = 'center'; + } else if (position.toLowerCase().includes('top')) { + origY = 'top'; + } else if (position.toLowerCase().includes('bottom')) { + origY = 'bottom'; + } else { + throw getMcPopoverInvalidPositionError(position); + } + + return origY; + } + + /** Returns the overlay position and a fallback position based on the user's preference */ + getOverlayPosition(): {main: IOverlayConnectionPosition; fallback: IOverlayConnectionPosition} { + const position = this.mcPlacement; + let overlayPosition: IOverlayConnectionPosition; + if (this.availablePositions[position]) { + overlayPosition = { + overlayX : this.availablePositions[position].overlayX, + overlayY: this.availablePositions[position].overlayY + }; + } else { + throw getMcPopoverInvalidPositionError(position); + } + + const {x, y} = this.invertPosition(overlayPosition.overlayX, overlayPosition.overlayY); + + return { + main: overlayPosition, + fallback: {overlayX: x, overlayY: y} + }; + } + + /** Inverts an overlay position. */ + private invertPosition(x: HorizontalConnectionPos, y: VerticalConnectionPos) { + let newX: HorizontalConnectionPos = x; + let newY: VerticalConnectionPos = y; + if (this.mcPlacement === 'top' || this.mcPlacement === 'bottom') { + if (y === 'top') { + newY = 'bottom'; + } else if (y === 'bottom') { + newY = 'top'; + } + } else { + if (x === 'end') { + newX = 'start'; + } else if (x === 'start') { + newX = 'end'; + } + } + + return {x: newX, y: newY}; + } +} diff --git a/packages/mosaic/popover/popover.module.ts b/packages/mosaic/popover/popover.module.ts new file mode 100644 index 000000000..2dd3c99d3 --- /dev/null +++ b/packages/mosaic/popover/popover.module.ts @@ -0,0 +1,19 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { OverlayModule } from '@ptsecurity/cdk/overlay'; + +import { + McPopoverComponent, + McPopover, + MC_POPOVER_SCROLL_STRATEGY_FACTORY_PROVIDER +} from './popover.component'; + + +@NgModule({ + declarations: [McPopoverComponent, McPopover], + exports: [McPopoverComponent, McPopover], + imports: [CommonModule, OverlayModule], + providers: [MC_POPOVER_SCROLL_STRATEGY_FACTORY_PROVIDER], + entryComponents: [McPopoverComponent] +}) +export class McPopoverModule {} diff --git a/packages/mosaic/popover/popover.scss b/packages/mosaic/popover/popover.scss new file mode 100644 index 000000000..8ce80696b --- /dev/null +++ b/packages/mosaic/popover/popover.scss @@ -0,0 +1,194 @@ +@import '../core/theming/theming'; +@import '../core/styles/typography/typography-utils'; +@import '../core/styles/common/animation'; +@import '../core/styles/common/overlay'; + + +$z-index-popover: 1060; +$popover-max-width: 240px; +$border-radius-base: 4px; +$popover-arrow-width: 6px; +$popover-distance: $popover-arrow-width + 4px; +$border-size: 1px; + +$mc-popover-small-width: 280px; +$mc-popover-normal-width: 400px; +$mc-popover-large-width: 640px; + +.mc-popover { + position: relative; + + display: block; + + margin: 0; + padding: 0; + box-sizing: border-box; + + visibility: visible; + + z-index: $z-index-popover; + + list-style: none; + white-space: pre-line; +} + +.mc-popover-small { + max-width: $mc-popover-small-width; + + .mc-popover { + max-width: $mc-popover-small-width !important; + } +} + +.mc-popover-normal { + max-width: $mc-popover-normal-width; + + .mc-popover { + max-width: $mc-popover-normal-width !important; + } +} + +.mc-popover-large { + max-width: $mc-popover-large-width; + + .mc-popover { + max-width: $mc-popover-large-width !important; + } +} + +.mc-popover__container { + font-size: 15px; + border-radius: $border-radius-base; + border-width: $border-size; + border-style: solid; + overflow: hidden; +} + +.mc-popover__header { + padding: 10px 16px; + border-bottom-width: $border-size; + border-bottom-style: solid; +} + +.mc-popover__content { + padding: 16px; +} + +.mc-popover__footer { + margin-top: 8px; + padding: 12px 16px; + border-top-width: $border-size; + border-top-style: solid; +} + +.mc-popover_placement-top, +.mc-popover_placement-top-left, +.mc-popover_placement-top-right{ + .mc-popover { + padding-bottom: $popover-distance; + } +} + +.mc-popover_placement-right, +.mc-popover_placement-right-top, +.mc-popover_placement-right-bottom { + .mc-popover { + padding-left: $popover-distance; + } +} + +.mc-popover_placement-bottom, +.mc-popover_placement-bottom-left, +.mc-popover_placement-bottom-right { + .mc-popover { + padding-top: $popover-distance; + } +} + +.mc-popover_placement-left, +.mc-popover_placement-left-top, +.mc-popover_placement-left-bottom { + .mc-popover { + padding-right: $popover-distance; + } +} + +.mc-popover__arrow { + position: absolute; + width: 14px; + height: 14px; + border: solid 1px; + transform: rotate(45deg); +} + +.mc-popover_placement-top .mc-popover__arrow { + bottom: $popover-distance - $popover-arrow-width; + left: 50%; + margin-left: -$popover-arrow-width; +} + +.mc-popover_placement-top-left .mc-popover__arrow { + bottom: $popover-distance - $popover-arrow-width; + left: 20px; + margin-left: 0; +} + +.mc-popover_placement-top-right .mc-popover__arrow { + bottom: $popover-distance - $popover-arrow-width; + right: 20px; + margin-left: 0; +} + +.mc-popover_placement-right .mc-popover__arrow { + left: $popover-distance - $popover-arrow-width; + top: 50%; + margin-top: -$popover-arrow-width; +} + +.mc-popover_placement-right-top .mc-popover__arrow { + left: $popover-distance - $popover-arrow-width; + top: 20px; + margin-top: -$popover-arrow-width; +} + +.mc-popover_placement-right-bottom .mc-popover__arrow { + left: $popover-distance - $popover-arrow-width; + bottom: 14px; // same as top placement = element.style.top + element.style.margin-top = 20px -6px + margin-top: -$popover-arrow-width; +} + +.mc-popover_placement-left .mc-popover__arrow { + right: $popover-distance - $popover-arrow-width; + top: 50%; + margin-top: -$popover-arrow-width; +} + +.mc-popover_placement-left-top .mc-popover__arrow { + right: $popover-distance - $popover-arrow-width; + top: 20px; + margin-top: -$popover-arrow-width; +} + +.mc-popover_placement-left-bottom .mc-popover__arrow { + right: $popover-distance - $popover-arrow-width; + bottom: 14px; // same as top placement = element.style.top + element.style.margin-top = 20px -6px + margin-top: -$popover-arrow-width; +} + +.mc-popover_placement-bottom .mc-popover__arrow { + top: $popover-distance - $popover-arrow-width; + left: 50%; + margin-left: -$popover-arrow-width; +} + +.mc-popover_placement-bottom-left .mc-popover__arrow { + top: $popover-distance - $popover-arrow-width; + left: 20px; + margin-left: 0; +} + +.mc-popover_placement-bottom-right .mc-popover__arrow { + top: $popover-distance - $popover-arrow-width; + right: 20px; + margin-left: 0; +} diff --git a/packages/mosaic/popover/popover.spec.ts b/packages/mosaic/popover/popover.spec.ts new file mode 100644 index 000000000..7facecfbb --- /dev/null +++ b/packages/mosaic/popover/popover.spec.ts @@ -0,0 +1,188 @@ +import { Component, ElementRef, ViewChild } from '@angular/core'; +import { fakeAsync, inject, tick, TestBed, flush } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { OverlayContainer } from '@ptsecurity/cdk/overlay'; +import { dispatchMouseEvent, dispatchFakeEvent } from '@ptsecurity/cdk/testing'; + +import { McPopoverModule } from './popover.module'; + + +// tslint:disable:no-magic-numbers +// tslint:disable:max-line-length +// tslint:disable:no-console + +describe('McPopover', () => { + let overlayContainer: OverlayContainer; + let overlayContainerElement: HTMLElement; + let componentFixture; + let component; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports : [ McPopoverModule, NoopAnimationsModule ], + declarations: [ McPopoverTestComponent ] + }); + TestBed.compileComponents(); + })); + + beforeEach(inject([ OverlayContainer ], (oc: OverlayContainer) => { + overlayContainer = oc; + overlayContainerElement = oc.getContainerElement(); + })); + + afterEach(() => { + overlayContainer.ngOnDestroy(); + componentFixture.destroy(); + }); + + describe('Check test cases', () => { + beforeEach(() => { + componentFixture = TestBed.createComponent(McPopoverTestComponent); + component = componentFixture.componentInstance; + componentFixture.detectChanges(); + }); + + it('mcPopoverTrigger = hover', fakeAsync(() => { + const expectedValue = '_TEST1'; + const triggerElement = component.test1.nativeElement; + console.log(overlayContainerElement.textContent, 123123); + + expect(overlayContainerElement.textContent).not.toEqual(expectedValue); + + dispatchMouseEvent(triggerElement, 'mouseenter'); + tick(); + componentFixture.detectChanges(); + expect(overlayContainerElement.textContent).toEqual(expectedValue); + + dispatchMouseEvent(triggerElement, 'mouseleave'); + componentFixture.detectChanges(); + dispatchMouseEvent(overlayContainerElement, 'mouseenter'); + componentFixture.detectChanges(); + expect(overlayContainerElement.textContent).toContain(expectedValue); + // Move out from the tooltip element to hide it + dispatchMouseEvent(overlayContainerElement, 'mouseleave'); + tick(100); // tslint:disable-line + componentFixture.detectChanges(); + tick(); // wait for next tick to hide + expect(overlayContainerElement.textContent).not.toEqual(expectedValue); + })); + + it('mcPopoverTrigger = manual', fakeAsync(() => { + const expectedValue = '_TEST2'; + + expect(overlayContainerElement.textContent).not.toEqual(expectedValue); + + component.popoverVisibility = true; + tick(); + componentFixture.detectChanges(); + expect(overlayContainerElement.textContent).toEqual(expectedValue); + + component.popoverVisibility = false; + tick(); + componentFixture.detectChanges(); + tick(); + componentFixture.detectChanges(); + console.log(overlayContainerElement.textContent); + expect(overlayContainerElement.textContent).not.toEqual(expectedValue); + })); + + it('mcPopoverTrigger = focus', fakeAsync(() => { + const featureKey = '_TEST3'; + const triggerElement = component.test3.nativeElement; + dispatchFakeEvent(triggerElement, 'focus'); + componentFixture.detectChanges(); + expect(overlayContainerElement.textContent).toContain(featureKey); + dispatchFakeEvent(triggerElement, 'blur'); + tick(100); // tslint:disable-line + componentFixture.detectChanges(); + tick(); // wait for next tick to hide + componentFixture.detectChanges(); + tick(); // wait for next tick to hide + expect(overlayContainerElement.textContent).not.toContain(featureKey); + })); + + it('Can set mcPopoverHeader', fakeAsync(() => { + const expectedValue = '_TEST4'; + const triggerElement = component.test4.nativeElement; + + dispatchMouseEvent(triggerElement, 'mouseenter'); + tick(); + componentFixture.detectChanges(); + + const header = componentFixture.debugElement.query(By.css('.mc-popover__header')); + expect(header.nativeElement.textContent).toEqual(expectedValue); + + flush(); + })); + + it('Can set mcPopoverContent', fakeAsync(() => { + const expectedValue = '_TEST5'; + const triggerElement = component.test5.nativeElement; + + dispatchMouseEvent(triggerElement, 'mouseenter'); + tick(); + componentFixture.detectChanges(); + + const content = componentFixture.debugElement.query(By.css('.mc-popover__content')); + expect(content.nativeElement.textContent).toEqual(expectedValue); + + flush(); + })); + + it('Can set mcPopoverFooter', fakeAsync(() => { + const expectedValue = '_TEST6'; + const triggerElement = component.test6.nativeElement; + + dispatchMouseEvent(triggerElement, 'mouseenter'); + tick(); + componentFixture.detectChanges(); + + const footer = componentFixture.debugElement.query(By.css('.mc-popover__footer')); + expect(footer.nativeElement.textContent).toEqual(expectedValue); + + flush(); + })); + + it('Can set mcPopoverClass', fakeAsync(() => { + const expectedValue = '_TEST7'; + const triggerElement = component.test7.nativeElement; + + dispatchMouseEvent(triggerElement, 'mouseenter'); + tick(); + componentFixture.detectChanges(); + + const popover = componentFixture.debugElement.query(By.css('.mc-popover')); + expect(popover.nativeElement.classList.contains(expectedValue)).toBeTruthy(); + + flush(); + })); + }); +}); + +@Component({ + selector: 'mc-popover-test-component', + template: ` + + + + + + + + + + ` +}) +class McPopoverTestComponent { + popoverVisibility: boolean = false; + + @ViewChild('test1') test1: ElementRef; + @ViewChild('test2') test2: ElementRef; + @ViewChild('test3') test3: ElementRef; + @ViewChild('test4') test4: ElementRef; + @ViewChild('test5') test5: ElementRef; + @ViewChild('test6') test6: ElementRef; + @ViewChild('test7') test7: ElementRef; +} + diff --git a/packages/mosaic/popover/public-api.ts b/packages/mosaic/popover/public-api.ts new file mode 100644 index 000000000..0c3dd4ffc --- /dev/null +++ b/packages/mosaic/popover/public-api.ts @@ -0,0 +1,3 @@ +export * from './popover.module'; +export * from './popover.component'; +export * from './popover-animations'; diff --git a/packages/mosaic/popover/tsconfig.build.json b/packages/mosaic/popover/tsconfig.build.json new file mode 100644 index 000000000..030d35eb5 --- /dev/null +++ b/packages/mosaic/popover/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig.build", + "files": [ + "public-api.ts" + ], + "angularCompilerOptions": { + "annotateForClosureCompiler": true, + "strictMetadataEmit": true, + "flatModuleOutFile": "index.js", + "flatModuleId": "@ptsecurity/mosaic/popover", + "skipTemplateCodegen": true, + "fullTemplateTypeCheck": true + } +}