diff --git a/components/message/active-message.ts b/components/message/active-message.ts new file mode 100644 index 000000000..b661f0289 --- /dev/null +++ b/components/message/active-message.ts @@ -0,0 +1,40 @@ +import { MessageConfig } from "./message-config"; +import { ComponentRef } from "@angular/core"; +import { SuiMessage } from "./message"; + +export abstract class SuiActiveMessage { + public abstract onClick(callback:() => void):SuiActiveMessage; + public abstract onDismiss(callback:() => void):SuiActiveMessage; + + public abstract dismiss():void; +} + +export class ActiveMessage implements SuiActiveMessage { + public config:MessageConfig; + public componentRef:ComponentRef; + + public get component():SuiMessage { + return this.componentRef.instance; + } + + constructor(config:MessageConfig, componentRef:ComponentRef) { + this.config = config; + this.componentRef = componentRef; + + this.component.onDismiss.subscribe(() => this.componentRef.destroy()); + } + + public onClick(callback:() => void):ActiveMessage { + this.config.onClick.subscribe(() => callback()); + return this; + } + + public onDismiss(callback:() => void):ActiveMessage { + this.config.onDismiss.subscribe(() => callback()); + return this; + } + + public dismiss():void { + this.component.dismiss(); + } +} diff --git a/components/message/message-config.ts b/components/message/message-config.ts new file mode 100644 index 000000000..ef2888eaf --- /dev/null +++ b/components/message/message-config.ts @@ -0,0 +1,49 @@ +import { EventEmitter } from "@angular/core"; + +export type MessageState = "" | "info" | "success" | "warning" | "error"; + +export const MessageState = { + Default: "" as MessageState, + Info: "info" as MessageState, + Success: "success" as MessageState, + Warning: "warning" as MessageState, + Error: "error" as MessageState +}; + +export class MessageConfig { + public text:string; + public header:string; + public state:MessageState; + + public timeout:number; + public extendedTimeout:number; + + public hasDismissButton:boolean; + public hasProgress:boolean; + + public transition:string; + public transitionInDuration:number; + public transitionOutDuration:number; + + public onClick:EventEmitter; + public onDismiss:EventEmitter; + + constructor(text:string, state:MessageState = MessageState.Default, header?:string) { + this.text = text; + this.state = state; + this.header = header; + + this.timeout = 5000; + this.extendedTimeout = 1000; + + this.hasDismissButton = true; + this.hasProgress = false; + + this.transition = "fade"; + this.transitionInDuration = 400; + this.transitionOutDuration = 1000; + + this.onClick = new EventEmitter(); + this.onDismiss = new EventEmitter(); + } +} diff --git a/components/message/message-container.ts b/components/message/message-container.ts new file mode 100644 index 000000000..620349eb3 --- /dev/null +++ b/components/message/message-container.ts @@ -0,0 +1,94 @@ +import { Component, EventEmitter, Input, ComponentFactoryResolver, ViewContainerRef, ViewChild, ElementRef } from "@angular/core"; +import { MessageConfig } from "./message-config"; +import { ActiveMessage, SuiActiveMessage } from "./active-message"; +import { SuiMessage } from "./message"; +import { SuiComponentFactory } from "../util/component-factory.service"; +import { MessageController, IMessageController } from "./message-controller"; + +@Component({ + selector: "sui-message-container", + template: ` +
+`, + styles: [` +:host { + display: block; +} + +:host >>> sui-message { + display: block; + margin-bottom: 1rem; +} + +:host >>> sui-message:last-of-type { + margin-bottom: 0; +} + +:host >>> sui-message { + cursor: pointer; +} +`] +}) +export class SuiMessageContainer { + private _messages:ActiveMessage[]; + private _queue:ActiveMessage[]; + + @Input() + public set controller(controller:MessageController) { + controller.registerContainer(this); + } + + @ViewChild("containerSibling", { read: ViewContainerRef }) + public containerSibling:ViewContainerRef; + + constructor(private _componentFactory:SuiComponentFactory, private _element:ElementRef) { + this._messages = []; + this._queue = []; + } + + public show(config:MessageConfig, maxShown:number, showNewestFirst:boolean):SuiActiveMessage { + const componentRef = this._componentFactory.createComponent(SuiMessage); + componentRef.instance.loadConfig(config); + + const active = new ActiveMessage(config, componentRef) + .onDismiss(() => this.onMessageClose(active, showNewestFirst)); + + if (this._messages.length < maxShown) { + this.open(active, showNewestFirst); + } else { + this.queue(active); + } + + return active; + } + + private open(message:ActiveMessage, showNewestFirst:boolean):void { + this._messages.push(message); + + this._componentFactory.attachToView(message.componentRef, this.containerSibling); + if (!showNewestFirst) { + this._componentFactory.moveToElement(message.componentRef, this._element.nativeElement); + } + + message.component.show(); + } + + private queue(message:ActiveMessage):void { + this._queue.push(message); + } + + public dismissAll():void { + this._queue = []; + this._messages.forEach(m => m.dismiss()); + } + + private onMessageClose(message:ActiveMessage, showNewestFirst:boolean):void { + this._messages = this._messages.filter(m => m !== message); + + if (this._queue.length > 0) { + const queued = this._queue.shift(); + + this.open(queued, showNewestFirst); + } + } +} diff --git a/components/message/message-controller.ts b/components/message/message-controller.ts new file mode 100644 index 000000000..e2e161697 --- /dev/null +++ b/components/message/message-controller.ts @@ -0,0 +1,44 @@ +import { MessageConfig } from "./message-config"; +import { SuiActiveMessage } from "./active-message"; +import { SuiMessageContainer } from "./message-container"; + +export interface IMessageController { + maxShown:number; + isNewestOnTop:boolean; + show(config:MessageConfig):SuiActiveMessage; + dismissAll():void; +} + +export class MessageController implements IMessageController { + private _container:SuiMessageContainer; + + public maxShown:number; + public isNewestOnTop:boolean; + + constructor() { + this.maxShown = 7; + this.isNewestOnTop = true; + } + + public registerContainer(container:SuiMessageContainer):void { + this._container = container; + } + + public show(config:MessageConfig):SuiActiveMessage { + this.throwContainerError(); + + return this._container.show(config, this.maxShown, this.isNewestOnTop); + } + + public dismissAll():void { + this.throwContainerError(); + + return this._container.dismissAll(); + } + + private throwContainerError():void { + if (!this._container) { + throw new Error("You must pass this controller to a message container."); + } + } +} diff --git a/components/message/message-global-container.ts b/components/message/message-global-container.ts new file mode 100644 index 000000000..90d3c0c19 --- /dev/null +++ b/components/message/message-global-container.ts @@ -0,0 +1,86 @@ + +import { Component, HostListener } from "@angular/core"; +import { MessageController } from "./message-controller"; +import { SuiMessageService } from "./message-service"; +import { IDynamicClasslist } from "../util/interfaces"; +import { getDocumentFontSize } from "../util/util"; + +export type MessagePosition = "top" | "top-left" | "top-right" | + "bottom" | "bottom-left" | "bottom-right"; + +export const MessagePosition = { + Top: "top" as MessagePosition, + TopLeft: "top-left" as MessagePosition, + TopRight: "top-right" as MessagePosition, + Bottom: "bottom" as MessagePosition, + BottomLeft: "bottom-left" as MessagePosition, + BottomRight: "bottom-right" as MessagePosition +}; + +@Component({ + selector: "sui-message-global-container", + template: ` +
+ +
+`, + styles: [` +.global.container { + display: block; + position: fixed; +} + +.global.container.top { + top: 1rem; +} + +.global.container.bottom { + bottom: 1rem; +} + +.global.container.left { + left: 1rem; +} + +.global.container.right { + right: 1rem; +} + +.global.container:not(.left):not(.right) { + left: 1rem; +} +`] +}) +export class SuiMessageGlobalContainer { + public controller:MessageController; + + public position:MessagePosition; + public width:number; + + public get dynamicClasses():IDynamicClasslist { + const classes:IDynamicClasslist = {}; + + this.position + .split("-") + .forEach(p => classes[p] = true); + + return classes; + } + + public get dynamicWidth():number { + const margin = getDocumentFontSize(); + let width = this.width; + + if (this.position === MessagePosition.Top || + this.position === MessagePosition.Bottom || + window.innerWidth < width + margin * 2) { + + width = window.innerWidth - margin * 2; + } + + return width; + } + + @HostListener("window:resize") + public onDocumentResize():void {} +} diff --git a/components/message/message-service.ts b/components/message/message-service.ts new file mode 100644 index 000000000..415cdce75 --- /dev/null +++ b/components/message/message-service.ts @@ -0,0 +1,69 @@ +import { Injectable, ComponentRef } from "@angular/core"; +import { SuiComponentFactory } from "../util/component-factory.service"; +import { SuiMessageGlobalContainer, MessagePosition } from "./message-global-container"; +import { MessageController, IMessageController } from "./message-controller"; +import { MessageConfig } from "./message-config"; +import { SuiActiveMessage } from "./active-message"; + +@Injectable() +export class SuiMessageService implements IMessageController { + private _controller:MessageController; + private _containerRef:ComponentRef; + + private get _container():SuiMessageGlobalContainer { + return this._containerRef.instance; + } + + public get position():MessagePosition { + return this._container.position; + } + + public set position(position:MessagePosition) { + this._container.position = position; + } + + public get width():number { + return this._container.width; + } + + public set width(width:number) { + this._container.width = width; + } + + public get maxShown():number { + return this._controller.maxShown; + } + + public set maxShown(max:number) { + this._controller.maxShown = max; + } + + public get isNewestOnTop():boolean { + return this._controller.isNewestOnTop; + } + + public set isNewestOnTop(value:boolean) { + this._controller.isNewestOnTop = value; + } + + constructor(private _componentFactory:SuiComponentFactory) { + this._controller = new MessageController(); + + this._containerRef = this._componentFactory.createComponent(SuiMessageGlobalContainer); + this._container.controller = this._controller; + + this._componentFactory.attachToApplication(this._containerRef); + this._componentFactory.moveToDocumentBody(this._containerRef); + + this.position = MessagePosition.TopRight; + this.width = 480; + } + + public show(config:MessageConfig):SuiActiveMessage { + return this._controller.show(config); + } + + public dismissAll():void { + return this._controller.dismissAll(); + } +} diff --git a/components/message/message.module.ts b/components/message/message.module.ts index c94c12322..1b9f2d0c2 100644 --- a/components/message/message.module.ts +++ b/components/message/message.module.ts @@ -1,20 +1,48 @@ import { NgModule } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { SuiMessage, IMessage } from "./message"; import { SuiTransitionModule } from "../transition/transition.module"; +import { SuiProgressModule } from "../progress/progress.module"; +import { SuiMessageContainer } from "./message-container"; +import { SuiMessage, IMessage } from "./message"; +import { SuiUtilityModule } from "../util/util.module"; +import { MessageController } from "./message-controller"; +import { MessageConfig, MessageState } from "./message-config"; +import { SuiActiveMessage } from "./active-message"; +import { SuiMessageGlobalContainer } from "./message-global-container"; +import { SuiMessageService } from "./message-service"; @NgModule({ imports: [ CommonModule, - SuiTransitionModule + SuiTransitionModule, + SuiProgressModule, + SuiUtilityModule ], declarations: [ - SuiMessage + SuiMessage, + SuiMessageContainer, + SuiMessageGlobalContainer ], exports: [ - SuiMessage + SuiMessage, + SuiMessageContainer, + SuiMessageGlobalContainer + ], + providers: [ + SuiMessageService + ], + entryComponents: [ + SuiMessage, + SuiMessageGlobalContainer ] }) export class SuiMessageModule {} -export {IMessage}; +export { + IMessage, + SuiMessageContainer, + MessageController, + MessageConfig, + SuiActiveMessage, + MessageState +}; diff --git a/components/message/message.ts b/components/message/message.ts index 737ca6937..62ade01a0 100644 --- a/components/message/message.ts +++ b/components/message/message.ts @@ -1,6 +1,9 @@ -import { Component, Input, Output, EventEmitter, ElementRef, Renderer2, AfterViewInit } from "@angular/core"; -import { SuiTransition, Transition, TransitionDirection } from "../transition/transition"; +import { Component, EventEmitter, Input, Output, HostBinding } from "@angular/core"; import { TransitionController } from "../transition/transition-controller"; +import { MessageState, MessageConfig } from "./message-config"; +import { Transition, TransitionDirection } from "../transition/transition"; +import { HandledEvent } from "../util/util"; +import { IDynamicClasslist } from "../util/interfaces"; export interface IMessage { dismiss():void; @@ -9,49 +12,190 @@ export interface IMessage { @Component({ selector: "sui-message", template: ` -
- - +
+
+ + + + +
{{ header }}
+

{{ text }}

+
+
+
` }) export class SuiMessage implements IMessage { + public isDynamic:boolean; + public isClosing:boolean; + public isDismissing:boolean; + + public text:string; + public header:string; + public state:MessageState; + + public timeout:number; + public extendedTimeout:number; + public currentTimeout:number; + @Input() - public isDismissable:boolean; + public hasDismissButton:boolean; - @Output("dismiss") - public onDismiss:EventEmitter; + public hasProgress:boolean; - public isDismissed:boolean; + public timeoutProgress:number; public transitionController:TransitionController; @Input() public transition:string; + public transitionInDuration:number; - @Input() - public transitionDuration:number; + @Input("transitionDuration") + public transitionOutDuration:number; + + private _displayTimeout:number; + + @Output("click") + public onClick:EventEmitter; + + @Output("dismiss") + public onDismiss:EventEmitter; @Input("class") - public class:string; + public classes:string; + + public get dynamicClasses():IDynamicClasslist { + const classes:IDynamicClasslist = {}; + classes[this.state] = true; + + if (this.isDynamic && this.hasProgress) { + classes["attached"] = true; + } + + (this.classes || "") + .split(" ") + .forEach(c => classes[c] = true); + + return classes; + } constructor() { - this.isDismissable = true; - this.onDismiss = new EventEmitter(); + const config = new MessageConfig(""); + this.loadConfig(config); + + this.isDynamic = false; + this.transitionOutDuration = 300; + this.timeoutProgress = 100; + + this.transitionController = new TransitionController(false); + + this.show(); + } + + public loadConfig(config:MessageConfig):void { + this.isDynamic = true; - this.isDismissed = false; + this.text = config.text; + this.header = config.header; + this.state = config.state; - this.transitionController = new TransitionController(); - this.transition = "fade"; - this.transitionDuration = 300; + this.timeout = config.timeout; + this.extendedTimeout = config.extendedTimeout; - this.class = ""; + this.hasDismissButton = config.hasDismissButton; + this.hasProgress = config.hasProgress; + + this.transition = config.transition; + this.transitionInDuration = config.transitionInDuration; + this.transitionOutDuration = config.transitionOutDuration; + + this.onClick = config.onClick; + this.onDismiss = config.onDismiss; + } + + public show():void { + this.transitionController.stopAll(); + this.transitionController.animate( + new Transition( + this.transition, + this.isDynamic ? this.transitionInDuration : 0, + TransitionDirection.In, + () => { + if (this.isDynamic) { + this.beginTimer(this.timeout); + } + })); } public dismiss():void { - this.transitionController.animate(new Transition(this.transition, this.transitionDuration, TransitionDirection.Out, () => { - this.isDismissed = true; - this.onDismiss.emit(this); - })); + this.isDismissing = true; + this.transitionOutDuration = this.transitionInDuration; + + this.hide(); + } + + public hide():void { + this.isClosing = true; + + this.transitionController.stopAll(); + this.transitionController.animate( + new Transition( + this.transition, + this.transitionOutDuration, + TransitionDirection.Out, + () => { + this.isClosing = false; + this.onDismiss.emit(); + })); + } + + public beginTimer(timeout:number):void { + if (this.isDynamic && !this.isDismissing) { + this.timeoutProgress = 0; + this.currentTimeout = timeout; + this._displayTimeout = window.setTimeout(() => this.onTimedOut(), timeout); + } + } + + public cancelTimer():void { + if (this.isDynamic && !this.isDismissing) { + this.timeoutProgress = 100; + this.currentTimeout = 0; + clearTimeout(this._displayTimeout); + + if (this.isClosing) { + this.isClosing = false; + + this.transitionController.cancel(); + } + } + } + + public onClicked(e:HandledEvent):void { + if (!e.eventHandled) { + this.cancelTimer(); + this.onClick.emit(); + } + } + + public onDismissClicked(e:HandledEvent):void { + e.eventHandled = true; + this.dismiss(); + } + + private onTimedOut():void { + this.hide(); } } diff --git a/components/modal/active-modal.ts b/components/modal/active-modal.ts index 822187996..f35d8b9d4 100644 --- a/components/modal/active-modal.ts +++ b/components/modal/active-modal.ts @@ -2,22 +2,32 @@ import { ModalConfig } from "./modal-config"; import { SuiModal } from "./modal"; import { ComponentRef } from "@angular/core"; +export abstract class SuiActiveModal { + public abstract onApprove(callback:(result:U) => void):SuiActiveModal; + public abstract onDeny(callback:(result:V) => void):SuiActiveModal; + + public abstract approve(result:U):void; + public abstract deny(result:V):void; + + public abstract destroy():void; +} + // Helper class to support method chaining when calling `SuiModalService.open(...)`. -export class ActiveModal { - private _config:ModalConfig; - private _componentRef:ComponentRef>; +export class ActiveModal implements SuiActiveModal { + public config:ModalConfig; + public componentRef:ComponentRef>; // Shorthand for direct access to the `SuiModal` instance. public get component():SuiModal { - return this._componentRef.instance; + return this.componentRef.instance; } - constructor(instance:ModalConfig, componentRef:ComponentRef>) { - this._config = instance; - this._componentRef = componentRef; + constructor(config:ModalConfig, componentRef:ComponentRef>) { + this.config = config; + this.componentRef = componentRef; // Automatically destroy the modal component when it has been dismissed. - this.component.onDismiss.subscribe(() => this._componentRef.destroy()); + this.component.onDismiss.subscribe(() => this.componentRef.destroy()); } // Registers a callback for when `onApprove` is fired. @@ -44,6 +54,6 @@ export class ActiveModal { // Removes the modal component instantly, without transitions or firing any events. public destroy():void { - this._componentRef.destroy(); + this.componentRef.destroy(); } } diff --git a/components/modal/modal-config.ts b/components/modal/modal-config.ts index ee0ba4c15..90ccd4eb2 100644 --- a/components/modal/modal-config.ts +++ b/components/modal/modal-config.ts @@ -1,4 +1,4 @@ -import { TemplateRef } from "@angular/core"; +import { TemplateRef, Type } from "@angular/core"; import { ModalControls, ModalResult } from "./modal-controls"; import { ModalTemplate } from "./modal-template"; @@ -64,9 +64,9 @@ export class TemplateModalConfig extends ModalConfig extends ModalConfig { - public component:Function; + public component:Type; - constructor(component:Function, context:T = null, isClosable:boolean = true) { + constructor(component:Type, context:T = null, isClosable:boolean = true) { super(context, isClosable); this.component = component; diff --git a/components/modal/modal.module.ts b/components/modal/modal.module.ts index e0f950045..2d1bf0e95 100644 --- a/components/modal/modal.module.ts +++ b/components/modal/modal.module.ts @@ -2,10 +2,11 @@ import { NgModule } from "@angular/core"; import { CommonModule } from "@angular/common"; import { SuiDimmerModule } from "../dimmer/dimmer.module"; import { SuiTransitionModule } from "../transition/transition.module"; +import { SuiUtilityModule } from "../util/util.module"; import { SuiModalService } from "./modal.service"; -import { SuiModal } from "./modal"; +import { SuiModal, IModal } from "./modal"; import { Modal, ModalResult, ModalControls } from "./modal-controls"; -import { ActiveModal } from "./active-modal"; +import { ActiveModal, SuiActiveModal } from "./active-modal"; import { ModalConfig, TemplateModalConfig, ComponentModalConfig, ModalSize } from "./modal-config"; import { ModalTemplate } from "./modal-template"; @@ -13,7 +14,8 @@ import { ModalTemplate } from "./modal-template"; imports: [ CommonModule, SuiDimmerModule, - SuiTransitionModule + SuiTransitionModule, + SuiUtilityModule ], declarations: [ SuiModal @@ -33,9 +35,10 @@ export class SuiModalModule {} export { SuiModalService, Modal as SuiModal, + IModal, ModalResult, ModalControls, - ActiveModal as SuiActiveModal, + SuiActiveModal, ModalConfig, TemplateModalConfig, ComponentModalConfig, diff --git a/components/modal/modal.service.ts b/components/modal/modal.service.ts index e10e71713..269c638e9 100644 --- a/components/modal/modal.service.ts +++ b/components/modal/modal.service.ts @@ -2,51 +2,43 @@ import { Injectable, ApplicationRef, ComponentFactoryResolver, Injector, Type, R import { ModalConfig, TemplateModalConfig, ComponentModalConfig } from "./modal-config"; import { SuiModal } from "./modal"; import { Modal } from "./modal-controls"; -import { ActiveModal } from "./active-modal"; +import { ActiveModal, SuiActiveModal } from "./active-modal"; +import { SuiComponentFactory } from "../util/component-factory.service"; @Injectable() export class SuiModalService { - constructor(private _applicationRef:ApplicationRef, - private _componentFactoryResolver:ComponentFactoryResolver, - private _injector:Injector) {} + constructor(private _componentFactory:SuiComponentFactory) {} - public open(modal:ModalConfig):ActiveModal { - // Resolve factory for creating `SuiModal` components. - const factory = this._componentFactoryResolver.resolveComponentFactory>(SuiModal); + public open(modal:ModalConfig):SuiActiveModal { + // Generate the modal component to be shown. + const componentRef = this._componentFactory.createComponent>(SuiModal); - // Generate a component using the injector and the previously resolved factory. - const componentRef = factory.create(this._injector); // Shorthand for the created modal component instance. const modalComponent = componentRef.instance; if (modal instanceof TemplateModalConfig) { // Inject the template into the view. - componentRef.instance.templateSibling.createEmbeddedView(modal.template, { + this._componentFactory.createView(modalComponent.templateSibling, modal.template, { // `let-context` $implicit: modal.context, // `let-modal="modal"` modal: componentRef.instance.controls }); } else if (modal instanceof ComponentModalConfig) { - // Resolve factory for creating a new instance of the provided component. - const contentComponentFactory = this._componentFactoryResolver.resolveComponentFactory(modal.component as Type<{}>); - - // Provide an instance of `Modal` for the injector, to be used in the component constructor. - const modalContentInjector = ReflectiveInjector.resolveAndCreate( + // Generate the component to be used as the modal content, + // injecting an instance of `Modal` to be used in the component constructor. + const contentComponentRef = this._componentFactory.createComponent( + modal.component, [ { provide: Modal, useValue: new Modal(modalComponent.controls, modal.context) } - ], - this._injector + ] ); - // Generate a component using the custom injector and the previously resolved factory. - const contentComponentRef = contentComponentFactory.create(modalContentInjector); - // Insert the new component into the content of the modal. - modalComponent.templateSibling.insert(contentComponentRef.hostView); + this._componentFactory.attachToView(contentComponentRef, modalComponent.templateSibling); // Shorthand for access to the content component's DOM element. const contentElement = contentComponentRef.location.nativeElement as Element; @@ -65,11 +57,8 @@ export class SuiModalService { const templateElement = modalComponent.templateSibling.element.nativeElement as Element; templateElement.remove(); - // Attach the new modal component to the application. - this._applicationRef.attachView(componentRef.hostView); - - // Move the new modal component DOM to the document body. - document.querySelector("body").appendChild(componentRef.location.nativeElement); + this._componentFactory.attachToApplication(componentRef); + this._componentFactory.moveToDocumentBody(componentRef); // Initialise the generated modal with the provided config. modalComponent.loadConfig(modal); diff --git a/components/modal/modal.ts b/components/modal/modal.ts index 554b728d4..479c27317 100644 --- a/components/modal/modal.ts +++ b/components/modal/modal.ts @@ -4,10 +4,15 @@ import { } from "@angular/core"; import { TransitionController } from "../transition/transition-controller"; import { Transition, TransitionDirection } from "../transition/transition"; -import { KeyCode, parseBooleanAttribute } from "../util/util"; +import { KeyCode, parseBooleanAttribute, getDocumentFontSize } from "../util/util"; import { ModalControls, ModalResult } from "./modal-controls"; import { ModalConfig, ModalSize } from "./modal-config"; +export interface IModal { + approve(result:T):void; + deny(result:U):void; +} + @Component({ selector: "sui-modal", template: ` @@ -44,7 +49,7 @@ import { ModalConfig, ModalSize } from "./modal-config"; } `] }) -export class SuiModal implements OnInit, AfterViewInit { +export class SuiModal implements IModal, OnInit, AfterViewInit { @Input() // Determines whether the modal can be closed with a close button, clicking outside, or the escape key. public isClosable:boolean; @@ -219,8 +224,7 @@ export class SuiModal implements OnInit, AfterViewInit { // Decides whether the modal needs to reposition to allow scrolling. private updateScroll():void { // Semantic UI modal margin is 3.5rem, which is relative to the global font size, so for compatibility: - const fontSize = parseFloat(window.getComputedStyle(document.documentElement, null).getPropertyValue("font-size")); - const margin = fontSize * 3.5; + const margin = getDocumentFontSize() * 3.5; // _mustAlwaysScroll works by stopping _mustScroll from being automatically updated, so it stays `true`. if (!this._mustAlwaysScroll && this._modalElement) { diff --git a/components/popup/popup.directive.ts b/components/popup/popup.directive.ts index b24ddf567..2427e12ad 100644 --- a/components/popup/popup.directive.ts +++ b/components/popup/popup.directive.ts @@ -7,6 +7,7 @@ import { PositioningPlacement } from "../util/positioning.service"; import { ITemplateRefContext, parseBooleanAttribute } from "../util/util"; import { PopupConfig, IPopupConfig, PopupTrigger } from "./popup-config"; import { SuiPopupConfig } from "./popup.service"; +import { SuiComponentFactory } from "../util/component-factory.service"; export interface IPopup { open():void; @@ -92,8 +93,7 @@ export class SuiPopupDirective implements IPopup { private _openingTimeout:number; constructor(private _element:ElementRef, - private _viewContainerRef:ViewContainerRef, - private _componentFactoryResolver:ComponentFactoryResolver, + private _componentFactory:SuiComponentFactory, popupDefaults:SuiPopupConfig) { this.config = new PopupConfig(popupDefaults); @@ -107,15 +107,13 @@ export class SuiPopupDirective implements IPopup { this._openingTimeout = window.setTimeout( () => { if (!this._componentRef) { - // Resolve component factory for the `SuiPopup` component. - const factory = this._componentFactoryResolver.resolveComponentFactory(SuiPopup); - - // Generate a component using the view container reference and the previously resolved factory. - this._componentRef = this._viewContainerRef.createComponent(factory); + // Generate a new SuiPopup component and attach it to the application view. + this._componentRef = this._componentFactory.createComponent(SuiPopup); + this._componentFactory.attachToApplication(this._componentRef); // If there is a template, inject it into the view. if (this.config.template) { - this._popup.templateSibling.createEmbeddedView(this.config.template, { $implicit: this._popup }); + this._componentFactory.createView(this._popup.templateSibling, this.config.template, { $implicit: this._popup }); } // Configure popup with provided config, and attach a reference to the anchor element. @@ -123,7 +121,7 @@ export class SuiPopupDirective implements IPopup { this._popup.anchor = this._element; // Move the generated element to the body to avoid any positioning issues. - document.querySelector("body").appendChild(this._componentRef.location.nativeElement); + this._componentFactory.moveToDocumentBody(this._componentRef); // When the popup is closed (onClose fires on animation complete), this._popup.onClose.subscribe(() => { diff --git a/components/popup/popup.module.ts b/components/popup/popup.module.ts index c10182079..a8183039e 100644 --- a/components/popup/popup.module.ts +++ b/components/popup/popup.module.ts @@ -1,6 +1,7 @@ import { NgModule } from "@angular/core"; import { CommonModule } from "@angular/common"; import { SuiTransitionModule } from "../transition/transition.module"; +import { SuiUtilityModule } from "../util/util.module"; import { SuiPopupDirective, IPopup } from "./popup.directive"; import { SuiPopup } from "./popup"; import { SuiPopupArrow } from "./popup-arrow"; @@ -11,7 +12,8 @@ import { SuiPopupConfig } from "./popup.service"; @NgModule({ imports: [ CommonModule, - SuiTransitionModule + SuiTransitionModule, + SuiUtilityModule ], declarations: [ SuiPopupDirective, diff --git a/components/progress/progress.ts b/components/progress/progress.ts index 4ca7685ae..3cd74aa2a 100644 --- a/components/progress/progress.ts +++ b/components/progress/progress.ts @@ -3,7 +3,11 @@ import { Component, Input, HostBinding } from "@angular/core"; @Component({ selector: "sui-progress", template: ` -
+
{{ percentage }}%
@@ -12,7 +16,6 @@ import { Component, Input, HostBinding } from "@angular/core"; `, styles: [` .bar { - transition-duration: 300ms !important; z-index: 1; } `] @@ -96,6 +99,15 @@ export class SuiProgress { return percentage.toFixed(this.precision); } + @Input() + public transition:string; + + @Input() + public transitionDuration:number; + + @Input() + public canCompletelyEmpty:boolean; + @Input("class") public set classValue(classes:string) { if (classes.includes("attached") || classes.includes("tiny")) { @@ -115,6 +127,10 @@ export class SuiProgress { this.autoSuccess = true; this.showProgress = true; + this.transition = "ease"; + this.transitionDuration = 350; + this.canCompletelyEmpty = false; + this._popupClasses = true; } } diff --git a/components/sui.module.ts b/components/sui.module.ts index ae1cd64f3..b7d8fcbdc 100644 --- a/components/sui.module.ts +++ b/components/sui.module.ts @@ -15,6 +15,7 @@ import { SuiSidebarModule } from "./sidebar/sidebar.module"; import { SuiTabsModule } from "./tabs/tab.module"; import { SuiSelectModule } from "./select/select.module"; import { SuiTransitionModule } from "./transition/transition.module"; +import { SuiUtilityModule } from "./util/util.module"; @NgModule({ exports: [ @@ -32,7 +33,9 @@ import { SuiTransitionModule } from "./transition/transition.module"; SuiSelectModule, SuiSidebarModule, SuiTabsModule, - SuiTransitionModule + SuiTransitionModule, + + SuiUtilityModule ] }) export class SuiModule {} diff --git a/components/transition/transition-controller.ts b/components/transition/transition-controller.ts index 7e014d509..85b8c0583 100644 --- a/components/transition/transition-controller.ts +++ b/components/transition/transition-controller.ts @@ -131,12 +131,10 @@ export class TransitionController { } // Wait the length of the animation before calling the complete callback. - this._animationTimeout = window.setTimeout(() => this.finishTransition(transition), transition.duration); + this._animationTimeout = window.setTimeout(() => this.finalizeTransition(transition), transition.duration); } - // Called when a transition has completed. - private finishTransition(transition:Transition):void { - // Unset the Semantic UI classes & styles for transitioning. + private completeTransition(transition:Transition):void { transition.classes.forEach(c => this._renderer.removeClass(this._element, c)); this._renderer.removeClass(this._element, `animating`); this._renderer.removeClass(this._element, transition.directionClass); @@ -144,6 +142,19 @@ export class TransitionController { this._renderer.removeStyle(this._element, `animationDuration`); this._renderer.removeStyle(this._element, `display`); + // Delete the transition from the queue. + this._queue.shift(); + this._isAnimating = false; + + this._changeDetector.markForCheck(); + + clearTimeout(this._animationTimeout); + } + + // Called when a transition has completed. + private finalizeTransition(transition:Transition):void { + this.completeTransition(transition); + if (transition.direction === TransitionDirection.In) { // If we have just animated in, we are now visible. this._isVisible = true; @@ -158,12 +169,6 @@ export class TransitionController { transition.onComplete(); } - // Delete the transition from the queue. - this._queue.shift(); - this._isAnimating = false; - - this._changeDetector.markForCheck(); - // Immediately attempt to perform another transition. this.performTransition(); } @@ -174,8 +179,21 @@ export class TransitionController { return; } - clearTimeout(this._animationTimeout); - this.finishTransition(transition); + this.finalizeTransition(transition); + } + + // Cancels the current transition, leaves the rest of the queue intact. + public cancel(transition:Transition = this._queueFirst):void { + if (!transition || !this._isAnimating) { + return; + } + + this.completeTransition(transition); + + if (transition.direction === TransitionDirection.In) { + // Return hidden class if we were originally transitioning in. + this._isHidden = true; + } } // Stops the current transition, and empties the queue. @@ -184,6 +202,11 @@ export class TransitionController { this.stop(); } + public cancelAll():void { + this.clearQueue(); + this.cancel(); + } + // Empties the transition queue but carries on with the current transition. public clearQueue():void { if (this.isAnimating) { diff --git a/components/util/component-factory.service.ts b/components/util/component-factory.service.ts new file mode 100644 index 000000000..8f74c329b --- /dev/null +++ b/components/util/component-factory.service.ts @@ -0,0 +1,55 @@ +import { + Injectable, ApplicationRef, ComponentFactoryResolver, Injector, ComponentRef, + ReflectiveInjector, Provider, Type, ViewContainerRef, TemplateRef +} from "@angular/core"; + +export interface IImplicitContext { + $implicit?:T; +} + +@Injectable() +export class SuiComponentFactory { + constructor(private _applicationRef:ApplicationRef, + private _componentFactoryResolver:ComponentFactoryResolver, + private _injector:Injector) {} + + public createComponent(type:Type, providers:Provider[] = []):ComponentRef { + // Resolve a factory for creating components of type `type`. + const factory = this._componentFactoryResolver.resolveComponentFactory(type as Type); + + // Resolve and create an injector with the specified providers. + const injector = ReflectiveInjector.resolveAndCreate( + providers, + this._injector + ); + + // Create a component using the previously resolved factory & injector. + const componentRef = factory.create(injector); + + return componentRef; + } + + public createView>(viewContainer:ViewContainerRef, template:TemplateRef, context:U):void { + viewContainer.createEmbeddedView(template, context); + } + + // Inserts the component into the specified view container. + public attachToView(componentRef:ComponentRef, viewContainer:ViewContainerRef):void { + viewContainer.insert(componentRef.hostView, 0); + } + + // Inserts the component in the root application node. + public attachToApplication(componentRef:ComponentRef):void { + this._applicationRef.attachView(componentRef.hostView); + } + + // Moves the component to the specified DOM element. + public moveToElement(componentRef:ComponentRef, element:Element):void { + element.appendChild(componentRef.location.nativeElement); + } + + // Moves the component to the document body. + public moveToDocumentBody(componentRef:ComponentRef):void { + this.moveToElement(componentRef, document.querySelector("body")); + } +} diff --git a/components/util/enums.ts b/components/util/enums.ts new file mode 100644 index 000000000..cb5f39f29 --- /dev/null +++ b/components/util/enums.ts @@ -0,0 +1,33 @@ +export type SuiColor = "" | "red" | "orange" | "yellow" | "olive" | + "green" | "teal" | "blue" | "violet" | + "purple" | "pink" | "brown" | "black"; + +export const SuiColor = { + Default: "" as SuiColor, + Red: "red" as SuiColor, + Orange: "orange" as SuiColor, + Yellow: "yellow" as SuiColor, + Olive: "olive" as SuiColor, + Green: "green" as SuiColor, + Teal: "teal" as SuiColor, + Blue: "blue" as SuiColor, + Violet: "violet" as SuiColor, + Purple: "purple" as SuiColor, + Pink: "pink" as SuiColor, + Brown: "brown" as SuiColor, + Black: "black" as SuiColor +}; + +export type SuiSize = "mini" | "tiny" | "small" | "" | "large" | "big" | "huge" | "massive"; + +export const SuiSize = { + Mini: "mini" as SuiSize, + Tiny: "tiny" as SuiSize, + Small: "small" as SuiSize, + Default: "" as SuiSize, + Large: "large" as SuiSize, + Big: "big" as SuiSize, + Huge: "huge" as SuiSize, + Massive: "massive" as SuiSize +}; + diff --git a/components/util/interfaces.ts b/components/util/interfaces.ts new file mode 100644 index 000000000..9ef7521ef --- /dev/null +++ b/components/util/interfaces.ts @@ -0,0 +1,3 @@ +export interface IDynamicClasslist { + [name:string]:true; +} diff --git a/components/util/util.module.ts b/components/util/util.module.ts new file mode 100644 index 000000000..818a07adc --- /dev/null +++ b/components/util/util.module.ts @@ -0,0 +1,11 @@ +import { NgModule } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { SuiComponentFactory } from "./component-factory.service"; + +@NgModule({ + imports: [CommonModule], + providers: [ + SuiComponentFactory + ] +}) +export class SuiUtilityModule {} diff --git a/components/util/util.ts b/components/util/util.ts index 44733bc11..302e7e303 100644 --- a/components/util/util.ts +++ b/components/util/util.ts @@ -56,3 +56,9 @@ export function parseBooleanAttribute(attributeValue:boolean):boolean { return value; } + +export function getDocumentFontSize():number { + return parseFloat(window + .getComputedStyle(document.documentElement, null) + .getPropertyValue("font-size")); +} diff --git a/demo/src/app/components/page-content/page-content.component.css b/demo/src/app/components/page-content/page-content.component.css index 87ba68ea3..bd3b20502 100644 --- a/demo/src/app/components/page-content/page-content.component.css +++ b/demo/src/app/components/page-content/page-content.component.css @@ -23,7 +23,7 @@ } } -@media only screen and (max-width: 425px) { +@media only screen and (max-width: 480px) { :host.ui.main.container { margin-left: 1em !important; margin-right: 1em !important; diff --git a/demo/src/app/components/page-title/page-title.component.css b/demo/src/app/components/page-title/page-title.component.css index 27e0f3100..6a8814696 100644 --- a/demo/src/app/components/page-title/page-title.component.css +++ b/demo/src/app/components/page-title/page-title.component.css @@ -26,7 +26,7 @@ } } -@media only screen and (max-width: 425px) { +@media only screen and (max-width: 480px) { :host.ui.masthead.segment { margin-bottom: 1em; padding: 1em; diff --git a/demo/src/app/pages/accordion/accordion.page.html b/demo/src/app/pages/accordion/accordion.page.html index 7c745e855..9f2dc395b 100644 --- a/demo/src/app/pages/accordion/accordion.page.html +++ b/demo/src/app/pages/accordion/accordion.page.html @@ -7,7 +7,7 @@

Examples

- +
Important Note

The accordion depends on the Web Animations API, which requires a polyfill for full browser diff --git a/demo/src/app/pages/collapse/collapse.page.html b/demo/src/app/pages/collapse/collapse.page.html index 6fcff0905..55209d5b6 100644 --- a/demo/src/app/pages/collapse/collapse.page.html +++ b/demo/src/app/pages/collapse/collapse.page.html @@ -14,7 +14,7 @@

Collapse

- +
Important Note

The collapse component uses the Web Animations API.

diff --git a/demo/src/app/pages/message/message.page.ts b/demo/src/app/pages/message/message.page.ts index a861e1106..70407db23 100644 --- a/demo/src/app/pages/message/message.page.ts +++ b/demo/src/app/pages/message/message.page.ts @@ -11,7 +11,7 @@ const exampleStandardTemplate = ` `; const exampleNoDismissTemplate = ` - +

Attached message!
@@ -32,7 +32,7 @@ export class MessagePage { selector: "", properties: [ { - name: "isDismissable", + name: "hasDismissButton", type: "boolean", description: "Sets whether or not the message has a dismiss button.", defaultValue: "true" @@ -55,6 +55,11 @@ export class MessagePage { name: "dismiss", type: "void", description: "Fires when the message is dismissed by the user." + }, + { + name: "click", + type: "void", + description: "Fires when the message is clicked by the user." } ] } diff --git a/demo/src/app/pages/popup/popup.page.ts b/demo/src/app/pages/popup/popup.page.ts index 6f9a2d1ac..0dcad529c 100644 --- a/demo/src/app/pages/popup/popup.page.ts +++ b/demo/src/app/pages/popup/popup.page.ts @@ -127,7 +127,7 @@ export class PopupPage { "right bottom" ]; - public position:string = "right bottom"; + public position:string = "top right"; public manualPopupMarkup:string = `
@@ -198,7 +198,7 @@ export class PopupExampleTemplate {} }) export class PopupExamplePlacement { @Input() - public position:string = "right bottom"; + public position:string = "top right"; } export const PopupPageComponents = [PopupPage, PopupExampleStandard, PopupExampleTemplate, PopupExamplePlacement]; diff --git a/demo/src/app/pages/progress/progress.page.ts b/demo/src/app/pages/progress/progress.page.ts index 9160890bf..54a94946b 100644 --- a/demo/src/app/pages/progress/progress.page.ts +++ b/demo/src/app/pages/progress/progress.page.ts @@ -91,6 +91,24 @@ export class ProgressPage { type: "boolean", description: "Sets whether or not the progress bar automatically turns green when value == maximum.", defaultValue: "true" + }, + { + name: "transition", + type: "number", + description: "Sets the transition function used when transitioning between values.", + defaultValue: "350" + }, + { + name: "transitionDuration", + type: "number", + description: "Sets the transition duration of the bar between values.", + defaultValue: "350" + }, + { + name: "canCompletelyEmpty", + type: "boolean", + description: "Sets whether the progress bar can empty completely.", + defaultValue: "false" } ] } diff --git a/demo/src/app/pages/test/test.page.html b/demo/src/app/pages/test/test.page.html index 4f0e4f388..8465bd8c3 100644 --- a/demo/src/app/pages/test/test.page.html +++ b/demo/src/app/pages/test/test.page.html @@ -7,6 +7,9 @@

Examples

- + + +
+
\ No newline at end of file diff --git a/demo/src/app/pages/test/test.page.ts b/demo/src/app/pages/test/test.page.ts index 8fb2db8a8..9128cd217 100644 --- a/demo/src/app/pages/test/test.page.ts +++ b/demo/src/app/pages/test/test.page.ts @@ -1,7 +1,27 @@ import { Component, AfterViewInit, ViewChild, TemplateRef } from "@angular/core"; +import { SuiMessageContainer } from "../../../../../components/message/message-container"; +import { MessageConfig, MessageState } from "../../../../../components/message/message-config"; +import { MessageController } from "../../../../../components/message/message-controller"; +import { SuiMessageService } from "../../../../../components/message/message-service"; +import { MessagePosition } from "../../../../../components/message/message-global-container"; @Component({ selector: "demo-page-test", templateUrl: "./test.page.html" }) -export class TestPage {} +export class TestPage { + public controller:MessageController; + + constructor(private _messageService:SuiMessageService) { + this.controller = new MessageController(); + this._messageService.position = MessagePosition.BottomRight; + this._messageService.isNewestOnTop = true; + } + + public open():void { + const message = new MessageConfig(Date.now().toString(), MessageState.Default, "Header"); + + // this.controller.show(message); + this._messageService.show(message); + } +} diff --git a/package.json b/package.json index 170e670a8..bf0d3625e 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "lint": "npm run lint:lib & npm run lint:demo", "compile:lib": "ngc", "compile:lib:w": "ngc -w", - "compile:demo": "ng build --prod --aot=false --base-href=/ng2-semantic-ui/", + "compile:demo": "ng build --prod --aot=false --progress=false --base-href=/ng2-semantic-ui/", "compile": "npm run compile:lib && npm run compile:demo", "package:lib": "rollup -c", "build:lib": "npm run lint:lib && npm run compile:lib && npm run package:lib", diff --git a/tslint.json b/tslint.json index 7cf902d7f..fdc426534 100644 --- a/tslint.json +++ b/tslint.json @@ -38,9 +38,9 @@ ["class", "pascal"], ["interface", "pascal", { "regex": "^I.*$" }], ["parameter", "camel"], - ["property", "static", "camel"], ["property", "private", "camel", "require-leading-underscore"], ["property", "protected", "camel", "require-leading-underscore"], + ["property", "camel"], ["method", "camel"], ["function", "camel"] ],