diff --git a/packages/core/src/components/animation-controller/animation-controller.tsx b/packages/core/src/components/animation-controller/animation-controller.tsx index 8ff99cb5f1..7d55b7596c 100644 --- a/packages/core/src/components/animation-controller/animation-controller.tsx +++ b/packages/core/src/components/animation-controller/animation-controller.tsx @@ -9,10 +9,10 @@ import { Animator } from './animator'; export class AnimationControllerImpl implements AnimationController { @Method() - create(animationBuilder?: AnimationBuilder, baseElm?: any): Promise { + create(animationBuilder?: AnimationBuilder, baseElm?: any, opts?: any): Promise { return new Promise(resolve => { if (animationBuilder) { - resolve(animationBuilder(Animator as any, baseElm)); + resolve(animationBuilder(Animator as any, baseElm, opts)); } else { resolve(new Animator() as any); } diff --git a/packages/core/src/components/animation-controller/animation-interface.tsx b/packages/core/src/components/animation-controller/animation-interface.tsx index 15d6580103..e315146333 100644 --- a/packages/core/src/components/animation-controller/animation-interface.tsx +++ b/packages/core/src/components/animation-controller/animation-interface.tsx @@ -1,5 +1,5 @@ export interface AnimationController { - create(animationBuilder?: AnimationBuilder, baseElm?: any): Promise; + create(animationBuilder?: AnimationBuilder, baseElm?: any, opts?: any): Promise; } export interface Animation { @@ -38,7 +38,7 @@ export interface Animation { export interface AnimationBuilder { - (Animation: Animation, baseElm?: HTMLElement): Animation; + (Animation: Animation, baseElm?: HTMLElement, opts?: any): Animation; } diff --git a/packages/core/src/components/toast-controller/toast-controller.tsx b/packages/core/src/components/toast-controller/toast-controller.tsx new file mode 100644 index 0000000000..73a947cd32 --- /dev/null +++ b/packages/core/src/components/toast-controller/toast-controller.tsx @@ -0,0 +1,66 @@ +import { Component, Listen, Method } from '@stencil/core'; +import { ToastEvent, ToastOptions, Toast } from '../../index'; + +@Component({ + tag: 'ion-toast-controller' +}) +export class ToastController { + private ids = 0; + private toastResolves: { [toastId: string]: Function } = {}; + private toasts: Toast[] = []; + + @Method() + create(opts?: ToastOptions) { + // create ionic's wrapping ion-toast component + const toast = document.createElement('ion-toast'); + const id = this.ids++; + + // give this toast a unique id + toast.id = `toast-${id}`; + toast.style.zIndex = (10000 + id).toString(); + + // convert the passed in toast options into props + // that get passed down into the new toast + Object.assign(toast, opts); + + // append the toast element to the document body + const appRoot = document.querySelector('ion-app') || document.body; + appRoot.appendChild(toast as any); + + // store the resolve function to be called later up when the toast loads + return new Promise(resolve => { + this.toastResolves[toast.id] = resolve; + }); + } + + @Listen('body:ionToastDidLoad') + protected viewDidLoad(ev: ToastEvent) { + const toast = ev.detail.toast; + const toastResolve = this.toastResolves[toast.id]; + if (toastResolve) { + toastResolve(toast); + delete this.toastResolves[toast.id]; + } + } + + @Listen('body:ionToastWillPresent') + protected willPresent(ev: ToastEvent) { + this.toasts.push(ev.detail.toast); + } + + @Listen('body:ionToastWillDismiss, body:ionToastDidUnload') + protected willDismiss(ev: ToastEvent) { + const index = this.toasts.indexOf(ev.detail.toast); + if (index > -1) { + this.toasts.splice(index, 1); + } + } + + @Listen('body:keyup.escape') + protected escapeKeyUp() { + const lastToast = this.toasts[this.toasts.length - 1]; + if (lastToast) { + lastToast.dismiss(); + } + } +} diff --git a/packages/core/src/components/toast/animations/ios.enter.ts b/packages/core/src/components/toast/animations/ios.enter.ts new file mode 100644 index 0000000000..509e0510e5 --- /dev/null +++ b/packages/core/src/components/toast/animations/ios.enter.ts @@ -0,0 +1,37 @@ +import { Animation } from '../../../index'; + +/** + * iOS Toast Enter Animation + */ +export default function( + Animation: Animation, + baseElm: HTMLElement, + position: string +) { + const baseAnimation = new Animation(); + + const wrapperAnimation = new Animation(); + const wrapperEle = baseElm.querySelector('.toast-wrapper'); + wrapperAnimation.addElement(wrapperEle); + + switch (position) { + case 'top': + wrapperAnimation.fromTo('translateY', '-100%', `${10}px`); + break; + case 'middle': + let topPosition = Math.floor( + baseElm.clientHeight / 2 - wrapperEle.clientHeight / 2 + ); + wrapperEle.style.top = `${topPosition}px`; + wrapperAnimation.fromTo('opacity', 0.01, 1); + break; + default: + wrapperAnimation.fromTo('translateY', '100%', `${0 - 10}px`); + break; + } + return baseAnimation + .addElement(baseElm) + .easing('cubic-bezier(.36,.66,.04,1)') + .duration(400) + .add(wrapperAnimation); +} diff --git a/packages/core/src/components/toast/animations/ios.leave.ts b/packages/core/src/components/toast/animations/ios.leave.ts new file mode 100644 index 0000000000..f49d102f52 --- /dev/null +++ b/packages/core/src/components/toast/animations/ios.leave.ts @@ -0,0 +1,32 @@ +import { Animation } from '../../../index'; + +/** + * iOS Toast Leave Animation + */ +export default function( + Animation: Animation, + baseElm: HTMLElement, + position: string +) { + const baseAnimation = new Animation(); + + const wrapperAnimation = new Animation(); + const wrapperEle = baseElm.querySelector('.toast-wrapper'); + wrapperAnimation.addElement(wrapperEle); + switch (position) { + case 'top': + wrapperAnimation.fromTo('translateY', `${10}px`, '-100%'); + break; + case 'middle': + wrapperAnimation.fromTo('opacity', 0.99, 0); + break; + default: + wrapperAnimation.fromTo('translateY', `${0 - 10}px`, '100%'); + break; + } + return baseAnimation + .addElement(baseElm) + .easing('cubic-bezier(.36,.66,.04,1)') + .duration(300) + .add(wrapperAnimation); +} diff --git a/packages/core/src/components/toast/test/basic.html b/packages/core/src/components/toast/test/basic.html new file mode 100644 index 0000000000..8f007b49da --- /dev/null +++ b/packages/core/src/components/toast/test/basic.html @@ -0,0 +1,53 @@ + + + + + + Ionic Toast + + + + + + + + + Popover + + + + + Show Toast Bottom + Show Toast Top + Show Toast Middle + Show Toast with long message + Show Toast with close button + Show Toast with close button and custom text + + + + + + + + diff --git a/packages/core/src/components/toast/toast.ios.scss b/packages/core/src/components/toast/toast.ios.scss new file mode 100644 index 0000000000..d539ee0baa --- /dev/null +++ b/packages/core/src/components/toast/toast.ios.scss @@ -0,0 +1,74 @@ +@import "../../themes/ionic.globals.ios"; +@import "./toast"; + +// iOS Toast +// -------------------------------------------------- + +/// @prop - Background of the toast wrapper +$toast-ios-background: rgba(0, 0, 0, .9) !default; + +/// @prop - Border radius of the toast wrapper +$toast-ios-border-radius: .65rem !default; + +/// @prop - Color of the toast title +$toast-ios-title-color: #fff !default; + +/// @prop - Font size of the toast title +$toast-ios-title-font-size: 1.4rem !default; + +// deprecated +$toast-ios-title-padding: null !default; + +/// @prop - Padding top of the toast title +$toast-ios-title-padding-top: 1.5rem !default; + +/// @prop - Padding end of the toast title +$toast-ios-title-padding-end: $toast-ios-title-padding-top !default; + +/// @prop - Padding bottom of the toast title +$toast-ios-title-padding-bottom: $toast-ios-title-padding-top !default; + +/// @prop - Padding start of the toast title +$toast-ios-title-padding-start: $toast-ios-title-padding-end !default; + + +.toast-ios .toast-wrapper { + @include position-horizontal(10px, 10px); + @include margin(auto); + @include border-radius($toast-ios-border-radius); + + position: absolute; + + z-index: $z-index-overlay-wrapper; + display: block; + + max-width: $toast-max-width; + + background: $toast-ios-background; +} + +.toast-ios .toast-wrapper.toast-top { + @include transform(translate3d(0, -100%, 0)); + + top: 0; +} + +.toast-ios .toast-wrapper.toast-bottom { + @include transform(translate3d(0, 100%, 0)); + + bottom: 0; +} + +.toast-ios .toast-wrapper.toast-middle { + opacity: .01; +} + +.toast-ios .toast-message { + font-size: $toast-ios-title-font-size; + + color: $toast-ios-title-color; + + @include deprecated-variable(padding, $toast-ios-title-padding) { + @include padding($toast-ios-title-padding-top, $toast-ios-title-padding-end, $toast-ios-title-padding-bottom, $toast-ios-title-padding-start); + } +} diff --git a/packages/core/src/components/toast/toast.md.scss b/packages/core/src/components/toast/toast.md.scss new file mode 100644 index 0000000000..abc8d2a8ca --- /dev/null +++ b/packages/core/src/components/toast/toast.md.scss @@ -0,0 +1,71 @@ +@import "../../themes/ionic.globals.md"; +@import "./toast"; + +// Material Design Toast +// -------------------------------------------------- + +/// @prop - Background of the toast wrapper +$toast-md-background: #333 !default; + +/// @prop - Color of the toast title +$toast-md-title-color: #fff !default; + +/// @prop - Font size of the toast title +$toast-md-title-font-size: 1.5rem !default; + +// deprecated +$toast-md-title-padding: null !default; + +/// @prop - Padding top of the toast title +$toast-md-title-padding-top: 19px !default; + +/// @prop - Padding end of the toast title +$toast-md-title-padding-end: 16px !default; + +/// @prop - Padding bottom of the toast title +$toast-md-title-padding-bottom: 17px !default; + +/// @prop - Padding start of the toast title +$toast-md-title-padding-start: $toast-md-title-padding-end !default; + + +.toast-md .toast-wrapper { + @include position-horizontal(0, 0); + @include margin(auto); + + position: absolute; + + z-index: $z-index-overlay-wrapper; + display: block; + + width: $toast-width; + max-width: $toast-max-width; + + background: $toast-md-background; +} + +.toast-md .toast-wrapper.toast-top { + @include transform(translate3d(0, -100%, 0)); + + top: 0; +} + +.toast-md .toast-wrapper.toast-bottom { + @include transform(translate3d(0, 100%, 0)); + + bottom: 0; +} + +.toast-md .toast-wrapper.toast-middle { + opacity: .01; +} + +.toast-md .toast-message { + font-size: $toast-md-title-font-size; + + color: $toast-md-title-color; + + @include deprecated-variable(padding, $toast-md-title-padding) { + @include padding($toast-md-title-padding-top, $toast-md-title-padding-end, $toast-md-title-padding-bottom, $toast-md-title-padding-start); + } +} diff --git a/packages/core/src/components/toast/toast.scss b/packages/core/src/components/toast/toast.scss new file mode 100644 index 0000000000..d67519128b --- /dev/null +++ b/packages/core/src/components/toast/toast.scss @@ -0,0 +1,49 @@ +@import "../../themes/ionic.globals"; + + +// Toast +// -------------------------------------------------- + +/// @prop - Width of the toast +$toast-width: 100% !default; + +/// @prop - Max width of the toast +$toast-max-width: 700px !default; + + +ion-toast { + @include position(0, null, null, 0); + + position: absolute; + + z-index: $z-index-overlay; + + display: block; + + width: $toast-width; + height: $toast-width; + + pointer-events: none; + + contain: strict; +} + +.toast-container { + display: flex; + + align-items: center; + + pointer-events: auto; + + contain: content; +} + +.toast-button { + @include padding(19px, 16px, 17px); + + font-size: 1.5rem; +} + +.toast-message { + flex: 1; +} diff --git a/packages/core/src/components/toast/toast.tsx b/packages/core/src/components/toast/toast.tsx new file mode 100644 index 0000000000..4106332bfb --- /dev/null +++ b/packages/core/src/components/toast/toast.tsx @@ -0,0 +1,202 @@ +import { Component, Element, Event, EventEmitter, Listen, Prop, State } from '@stencil/core'; +import { AnimationBuilder, Animation, AnimationController, Config, CssClassMap } from '../../index'; + +import { createThemedClasses } from '../../utils/theme'; + +import iOSEnterAnimation from './animations/ios.enter'; +import iOSLeaveAnimation from './animations/ios.leave'; + +@Component({ + tag: 'ion-toast', + styleUrls: { + ios: 'toast.ios.scss', + md: 'toast.md.scss', + wp: 'toast.wp.scss' + }, + host: { + theme: 'toast' + } +}) +export class Toast { + private animation: Animation; + + @Element() private el: HTMLElement; + + @Event() private ionToastDidLoad: EventEmitter; + @Event() private ionToastDidPresent: EventEmitter; + @Event() private ionToastWillPresent: EventEmitter; + @Event() private ionToastWillDismiss: EventEmitter; + @Event() private ionToastDidDismiss: EventEmitter; + @Event() private ionToastDidUnload: EventEmitter; + + @Prop({ connect: 'ion-animation-controller' }) animationCtrl: AnimationController; + @Prop({ context: 'config' }) config: Config; + + @Prop() message: string; + @Prop() cssClass: string; + @Prop() duration: number; + @Prop() showCloseButton: boolean; + @Prop() closeButtonText: string; + @Prop() dismissOnPageChange: boolean; + @Prop() position: string; + @Prop() enterAnimation: AnimationBuilder; + @Prop() exitAnimation: AnimationBuilder; + @Prop() id: string; + + present() { + return new Promise(resolve => { + this._present(resolve); + }); + } + + private _present(resolve: Function) { + if (this.animation) { + this.animation.destroy(); + this.animation = null; + } + this.ionToastWillPresent.emit({ actionSheet: this }); + + // get the user's animation fn if one was provided + let animationBuilder = this.enterAnimation; + + if (!animationBuilder) { + // user did not provide a custom animation fn + // decide from the config which animation to use + animationBuilder = iOSEnterAnimation; + } + + // build the animation and kick it off + this.animationCtrl.create(animationBuilder, this.el, this.position).then(animation => { + this.animation = animation; + + animation.onFinish((a: any) => { + a.destroy(); + this.ionViewDidEnter(); + resolve(); + }).play(); + }); + } + + dismiss() { + if (this.animation) { + this.animation.destroy(); + this.animation = null; + } + return new Promise(resolve => { + this.ionToastWillDismiss.emit({ toast: this }); + + // get the user's animation fn if one was provided + let animationBuilder = this.exitAnimation; + if (!animationBuilder) { + // user did not provide a custom animation fn + // decide from the config which animation to use + animationBuilder = iOSLeaveAnimation; + } + + // build the animation and kick it off + this.animationCtrl.create(animationBuilder, this.el, this.position).then(animation => { + this.animation = animation; + + animation.onFinish((a: any) => { + a.destroy(); + this.ionToastDidDismiss.emit({ toast: this }); + + Context.dom.write(() => { + this.el.parentNode.removeChild(this.el); + }); + + resolve(); + }).play(); + }); + }); + } + + protected ionViewDidUnload() { + this.ionToastDidUnload.emit({ toast: this }); + } + + @Listen('ionDismiss') + protected onDismiss(ev: UIEvent) { + ev.stopPropagation(); + ev.preventDefault(); + + this.dismiss(); + } + + protected ionViewDidLoad() { + this.ionToastDidLoad.emit({ toast: this }); + } + + protected ionViewDidEnter() { + this.ionToastDidPresent.emit({ toast: this }); + if(this.duration){ + setTimeout(()=>{ + this.dismiss(); + }, this.duration) + } + } + + protected click(button: HTMLElement) { + console.log(button) + // let shouldDismiss = true; + // if (button.handler) { + // if (button.handler() === false) { + // shouldDismiss = false; + // } + // } + // if (shouldDismiss) { + // this.dismiss(); + // } + } + + protected render() { + let userCssClass = 'toast-content'; + if (this.cssClass) { + userCssClass += ' ' + this.cssClass; + } + + return ( +
+
+ {this.message + ?
{this.message}
+ : null} + {this.showCloseButton + ? this.dismiss()}> + {this.closeButtonText || 'Close'} + + : null} +
+
+ ); + } + + + wrapperClass(): CssClassMap { + let wrapperClass: string[] = !this.position + ? ['toast-wrapper','toast-bottom'] + : [`toast-wrapper`, `toast-${this.position}`]; + return wrapperClass.reduce((prevValue: any, cssClass: any) => { + prevValue[cssClass] = true; + return prevValue; + }, {}); + } +} + +export interface ToastOptions { + message?: string; + cssClass?: string; + duration?: number; + showCloseButton?: boolean; + closeButtonText?: string; + dismissOnPageChange?: boolean; + position?: string; + enterAnimation?: AnimationBuilder; + exitAnimation?: AnimationBuilder; +} + +export interface ToastEvent { + detail: { + toast: Toast; + }; +} diff --git a/packages/core/src/components/toast/toast.wp.scss b/packages/core/src/components/toast/toast.wp.scss new file mode 100644 index 0000000000..cacaf062a7 --- /dev/null +++ b/packages/core/src/components/toast/toast.wp.scss @@ -0,0 +1,81 @@ +@import "../../themes/ionic.globals.wp"; +@import "./toast"; + +// Windows Phone Toast +// -------------------------------------------------- + +/// @prop - Background of the toast wrapper +$toast-wp-background: rgba(0, 0, 0, 1) !default; + +/// @prop - Border radius of the toast wrapper +$toast-wp-border-radius: 0 !default; + +/// @prop - Color of the toast button +$toast-wp-button-color: #fff !default; + +/// @prop - Color of the toast title +$toast-wp-title-color: #fff !default; + +/// @prop - Font size of the toast title +$toast-wp-title-font-size: 1.4rem !default; + +// deprecated +$toast-wp-title-padding: null !default; + +/// @prop - Padding top of the toast title +$toast-wp-title-padding-top: 1.5rem !default; + +/// @prop - Padding end of the toast title +$toast-wp-title-padding-end: $toast-wp-title-padding-top !default; + +/// @prop - Padding bottom of the toast title +$toast-wp-title-padding-bottom: $toast-wp-title-padding-top !default; + +/// @prop - Padding start of the toast title +$toast-wp-title-padding-start: $toast-wp-title-padding-end !default; + + +.toast-wp .toast-wrapper { + @include position-horizontal(0, 0); + @include margin(auto); + @include border-radius($toast-wp-border-radius); + + position: absolute; + + z-index: $z-index-overlay-wrapper; + display: block; + + max-width: $toast-max-width; + + background: $toast-wp-background; +} + +.toast-wp .toast-wrapper.toast-top { + top: 0; + + opacity: .01; +} + +.toast-wp .toast-wrapper.toast-bottom { + bottom: 0; + + opacity: .01; +} + +.toast-wp .toast-wrapper.toast-middle { + opacity: .01; +} + +.toast-message { + font-size: $toast-wp-title-font-size; + + color: $toast-wp-title-color; + + @include deprecated-variable(padding, $toast-wp-title-padding) { + @include padding($toast-wp-title-padding-top, $toast-wp-title-padding-end, $toast-wp-title-padding-bottom, $toast-wp-title-padding-start); + } +} + +.toast-button { + color: $toast-wp-button-color; +} diff --git a/packages/core/src/index.d.ts b/packages/core/src/index.d.ts index f53677d038..232c3c07be 100644 --- a/packages/core/src/index.d.ts +++ b/packages/core/src/index.d.ts @@ -17,6 +17,10 @@ import { PopoverController } from './components/popover-controller/popover-contr import { Scroll, ScrollCallback, ScrollDetail } from './components/scroll/scroll'; import { Segment } from './components/segment/segment'; import { SegmentButton, SegmentButtonEvent } from './components/segment-button/segment-button'; + +import { Toast, ToastEvent, ToastOptions } from './components/toast/toast' +import { ToastController } from './components/toast-controller/toast-controller' + import * as Stencil from '@stencil/core'; @@ -78,5 +82,9 @@ export { ScrollDetail, Segment, SegmentButton, - SegmentButtonEvent + SegmentButtonEvent, + Toast, + ToastEvent, + ToastOptions, + ToastController } diff --git a/packages/core/stencil.config.js b/packages/core/stencil.config.js index 1402970565..4b4544924f 100644 --- a/packages/core/stencil.config.js +++ b/packages/core/stencil.config.js @@ -27,6 +27,7 @@ exports.config = { { components: ['ion-spinner'] }, { components: ['ion-tabs', 'ion-tab', 'ion-tab-bar', 'ion-tab-button', 'ion-tab-highlight'] }, { components: ['ion-toggle'] }, + { components: ['ion-toast', 'ion-toast-controller'] }, { components: ['ion-nav', 'page-one', 'page-two', 'page-three'] } ], preamble: '(C) Ionic http://ionicframework.com - MIT License',