diff --git a/docs/pages/components/Notification.md b/docs/pages/components/Notification.md
new file mode 100644
index 000000000..738c68907
--- /dev/null
+++ b/docs/pages/components/Notification.md
@@ -0,0 +1,191 @@
+# Notification
+
+> Displays a global notification message at a corner of the page.
+
+## Example
+
+Click on the button below to show a notification. By default, it is dismissible with a close button, and will dismiss automatically after 5000ms (both are configurable).
+
+```html
+
+
+ Default Notification
+ No Auto-dismiss Notification
+
+
+
+
+```
+
+## Types
+
+There're 4 optional types of notification: `info` / `success` / `warning` / `danger`.
+
+Notification with specific type will has a default icon on the left, you can also change or remove the icon by `icon` option.
+
+```html
+
+
+ Info
+ Success
+ Warning
+ Danger
+
+
+
+
+```
+
+## Placements
+
+Notifications can be placed on any corner on a page.
+
+The `position` prop defines which corner a notification will slide in. It can be `top-right` (default), `top-left`, `bottom-right` or `bottom-left`.
+
+```html
+
+
+ Top Right (Default)
+ Bottom Right
+ Bottom Left
+ Top Left
+
+
+
+
+```
+
+## Dismissible
+
+By default a notification is dismissible with a close button, you can hide it by setting `dismissible` to `false`.
+
+```html
+
+ Notification Without Dismiss Button
+
+
+
+```
+
+## Global Method
+
+`$notify(options, callback)` global method for `Vue.prototype` will be added **if uiv is installed**.
+
+Note that the dismissed callback is optional.
+
+The method will return a `Promise` object that resolve while the notification is dismissed (if supported by browser or with es6 promise polyfill).
+
+## Import Individually
+
+If you prefer importing `Notification` individually:
+
+```javascript
+import { Notification } from 'uiv'
+```
+
+The corresponding method is `Notification.notify`, with same parameters as above.
+
+# API Reference
+
+## [Notification.vue](https://github.com/wxsms/uiv/tree/master/src/services/notification/Notification.vue)
+
+These props are used as `options` in the methods above.
+
+### Props
+
+Name | Type | Default | Required | Description
+---------- | ---------- | -------- | -------- | -----------------------
+`title` | String | | | The notification title.
+`content` | String | | | The notification content.
+`type` | String | | | Support: `info` / `success` / `warning` / `danger`.
+`duration` | Number | 5000 | | Dismiss after milliseconds, use 0 to prevent self-closing.
+`dismissible` | Boolean | true | | Show dismiss button.
+`placement` | String | top-right | | Support: `top-right` / `top-left` / `bottom-right` / `bottom-left`.
+`icon` | String | | | Custom icon class, use an empty string to disable icon.
+
+## [notification.js](https://github.com/wxsms/uiv/tree/master/src/services/notification/notification.js)
+
+This file has no props.
diff --git a/docs/router/routes.js b/docs/router/routes.js
index 6821379fd..17ba9c85d 100644
--- a/docs/router/routes.js
+++ b/docs/router/routes.js
@@ -71,6 +71,11 @@ const routes = [
meta: {type: 'component', label: 'Modal'},
component: () => import('./../pages/components/Modal.md')
},
+ {
+ path: '/notification',
+ meta: {type: 'component', label: 'Notification'},
+ component: () => import('./../pages/components/Notification.md')
+ },
{
path: '/pagination',
meta: {type: 'component', label: 'Pagination'},
diff --git a/src/services/index.js b/src/services/index.js
index 35f83776b..4edec0403 100644
--- a/src/services/index.js
+++ b/src/services/index.js
@@ -1 +1,2 @@
export {default as MessageBox} from './messagebox/messageBox.js'
+export {default as Notification} from './notification/notification.js'
diff --git a/src/services/notification/Notification.vue b/src/services/notification/Notification.vue
new file mode 100644
index 000000000..ea6b49ab8
--- /dev/null
+++ b/src/services/notification/Notification.vue
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
diff --git a/src/services/notification/constants.js b/src/services/notification/constants.js
new file mode 100644
index 000000000..f01568b66
--- /dev/null
+++ b/src/services/notification/constants.js
@@ -0,0 +1,13 @@
+export const TYPES = {
+ SUCCESS: 'success',
+ INFO: 'info',
+ DANGER: 'danger',
+ WARNING: 'warning'
+}
+
+export const PLACEMENTS = {
+ TOP_LEFT: 'top-left',
+ TOP_RIGHT: 'top-right',
+ BOTTOM_LEFT: 'bottom-left',
+ BOTTOM_RIGHT: 'bottom-right'
+}
diff --git a/src/services/notification/notification.js b/src/services/notification/notification.js
new file mode 100644
index 000000000..3c8012690
--- /dev/null
+++ b/src/services/notification/notification.js
@@ -0,0 +1,66 @@
+import {removeFromDom} from '@src/utils/domUtils'
+import {spliceIfExist} from '@src/utils/arrayUtils'
+import {isFunction, isExist} from '@src/utils/objectUtils'
+import Notification from './Notification.vue'
+import {PLACEMENTS} from './constants'
+import Vue from 'vue'
+
+const queues = {
+ [PLACEMENTS.TOP_LEFT]: [],
+ [PLACEMENTS.TOP_RIGHT]: [],
+ [PLACEMENTS.BOTTOM_LEFT]: [],
+ [PLACEMENTS.BOTTOM_RIGHT]: []
+}
+
+const body = document.body
+
+const destroy = (queue, instance) => {
+ // console.log('destroyNotification')
+ removeFromDom(instance.$el)
+ instance.$destroy()
+ spliceIfExist(queue, instance)
+}
+
+const init = (options, cb, resolve = null, reject = null) => {
+ const placement = options.placement
+ const queue = queues[placement]
+ // check if placement is valid
+ if (!isExist(queue)) {
+ return
+ }
+ const Constructor = Vue.extend(Notification)
+ let instance = new Constructor({
+ propsData: {
+ queue,
+ placement,
+ ...options,
+ cb (msg) {
+ destroy(queue, instance)
+ if (isFunction(cb)) {
+ cb(msg)
+ } else if (resolve && reject) {
+ resolve(msg)
+ }
+ }
+ }
+ })
+ instance.$mount()
+ body.appendChild(instance.$el)
+ queue.push(instance)
+}
+
+const notify = (options = {}, cb) => {
+ // set default placement as top-right
+ if (!isExist(options.placement)) {
+ options.placement = PLACEMENTS.TOP_RIGHT
+ }
+ if (isExist(window.Promise)) {
+ return new Promise((resolve, reject) => {
+ init(options, cb, resolve, reject)
+ })
+ } else {
+ init(options, cb)
+ }
+}
+
+export default {notify}
diff --git a/test/unit/specs/Notification.spec.js b/test/unit/specs/Notification.spec.js
new file mode 100644
index 000000000..26f4b1428
--- /dev/null
+++ b/test/unit/specs/Notification.spec.js
@@ -0,0 +1,300 @@
+import Vue from 'vue'
+import $ from 'jquery'
+import Notification from '@src/services/notification/notification'
+import NotificationDoc from '@docs/pages/components/Notification.md'
+import utils from '../utils'
+
+const OFFSET = '15px'
+
+describe('Notification', () => {
+ let vm
+ let $el
+ let spy
+ let savedLog
+
+ beforeEach(() => {
+ savedLog = console.log
+ console.log = function () {
+ return true
+ }
+ const Constructor = Vue.extend(NotificationDoc)
+ vm = new Constructor().$mount()
+ $el = $(vm.$el)
+ spy = sinon.spy(console, 'log')
+ })
+
+ afterEach(() => {
+ console.log.restore()
+ console.log = savedLog
+ vm.$destroy()
+ $el.remove()
+ $('.alert').remove()
+ })
+
+ it('should be able to use notification', async () => {
+ const _vm = vm.$refs['notification-example']
+ await vm.$nextTick()
+ const trigger = _vm.$el.querySelectorAll('.btn')[0]
+ trigger.click()
+ await utils.sleep(utils.transitionDuration)
+ await vm.$nextTick()
+ const alert = document.querySelector('.alert')
+ expect(alert).to.exist
+ expect(alert.className).to.contain('alert-info')
+ expect(alert.className).to.contain('alert-dismissible')
+ expect(alert.className).to.contain('fade')
+ expect(alert.className).to.contain('in')
+ expect(alert.querySelector('.media-heading').textContent).to.equal('Title')
+ expect(alert.querySelector('.media-body > div').textContent).to.equal('This is a notify message.')
+ alert.querySelector('button.close').click()
+ await utils.sleep(utils.transitionDuration)
+ await vm.$nextTick()
+ expect(document.querySelector('.alert')).not.exist
+ sinon.assert.calledWith(spy, 'dismissed')
+ })
+
+ it('should be able to use no auto-dismiss notification', async () => {
+ const _vm = vm.$refs['notification-example']
+ await vm.$nextTick()
+ const trigger = _vm.$el.querySelectorAll('.btn')[1]
+ trigger.click()
+ await utils.sleep(utils.transitionDuration)
+ await vm.$nextTick()
+ const alert = document.querySelector('.alert')
+ expect(alert).to.exist
+ expect(alert.className).to.contain('alert-info')
+ expect(alert.className).to.contain('alert-dismissible')
+ expect(alert.className).to.contain('fade')
+ expect(alert.className).to.contain('in')
+ expect(alert.querySelector('.media-heading').textContent).to.equal('Title')
+ expect(alert.querySelector('.media-body > div').textContent).to.equal('This notification will not dismiss automatically.')
+ await utils.sleep(5200)
+ expect(document.querySelector('.alert')).to.exist
+ alert.querySelector('button.close').click()
+ await utils.sleep(utils.transitionDuration)
+ await vm.$nextTick()
+ expect(document.querySelector('.alert')).not.exist
+ sinon.assert.calledWith(spy, 'dismissed')
+ }).timeout(5000 + 2000)
+
+ it('should be able to use `type=info` notification', async () => {
+ const _vm = vm.$refs['notification-types']
+ await vm.$nextTick()
+ const trigger = _vm.$el.querySelectorAll('.btn')[0]
+ trigger.click()
+ await utils.sleep(utils.transitionDuration)
+ await vm.$nextTick()
+ const alert = document.querySelector('.alert')
+ expect(alert).to.exist
+ expect(alert.className).to.contain('alert-info')
+ expect(alert.querySelectorAll('.media-left > .glyphicon').length).to.equal(1)
+ expect(alert.querySelectorAll('.media-left > .glyphicon-info-sign')).to.exist
+ alert.querySelector('button.close').click()
+ await utils.sleep(utils.transitionDuration)
+ await vm.$nextTick()
+ expect(document.querySelector('.alert')).not.exist
+ })
+
+ it('should be able to use `type=success` notification', async () => {
+ const _vm = vm.$refs['notification-types']
+ await vm.$nextTick()
+ const trigger = _vm.$el.querySelectorAll('.btn')[1]
+ trigger.click()
+ await utils.sleep(utils.transitionDuration)
+ await vm.$nextTick()
+ const alert = document.querySelector('.alert')
+ expect(alert).to.exist
+ expect(alert.className).to.contain('alert-success')
+ expect(alert.querySelectorAll('.media-left > .glyphicon').length).to.equal(1)
+ expect(alert.querySelectorAll('.media-left > .glyphicon-ok-sign')).to.exist
+ alert.querySelector('button.close').click()
+ await utils.sleep(utils.transitionDuration)
+ await vm.$nextTick()
+ expect(document.querySelector('.alert')).not.exist
+ })
+
+ it('should be able to use `type=warning` notification', async () => {
+ const _vm = vm.$refs['notification-types']
+ await vm.$nextTick()
+ const trigger = _vm.$el.querySelectorAll('.btn')[2]
+ trigger.click()
+ await utils.sleep(utils.transitionDuration)
+ await vm.$nextTick()
+ const alert = document.querySelector('.alert')
+ expect(alert).to.exist
+ expect(alert.className).to.contain('alert-warning')
+ expect(alert.querySelectorAll('.media-left > .glyphicon').length).to.equal(1)
+ expect(alert.querySelectorAll('.media-left > .glyphicon-info-sign')).to.exist
+ alert.querySelector('button.close').click()
+ await utils.sleep(utils.transitionDuration)
+ await vm.$nextTick()
+ expect(document.querySelector('.alert')).not.exist
+ })
+
+ it('should be able to use `type=danger` notification', async () => {
+ const _vm = vm.$refs['notification-types']
+ await vm.$nextTick()
+ const trigger = _vm.$el.querySelectorAll('.btn')[3]
+ trigger.click()
+ await utils.sleep(utils.transitionDuration)
+ await vm.$nextTick()
+ const alert = document.querySelector('.alert')
+ expect(alert).to.exist
+ expect(alert.className).to.contain('alert-danger')
+ expect(alert.querySelectorAll('.media-left > .glyphicon').length).to.equal(1)
+ expect(alert.querySelectorAll('.media-left > .glyphicon-remove-sign')).to.exist
+ alert.querySelector('button.close').click()
+ await utils.sleep(utils.transitionDuration)
+ await vm.$nextTick()
+ expect(document.querySelector('.alert')).not.exist
+ })
+
+ it('should be able to use `placement=top-right` notification', async () => {
+ const _vm = vm.$refs['notification-placements']
+ await vm.$nextTick()
+ const trigger = _vm.$el.querySelectorAll('.btn')[0]
+ trigger.click()
+ await utils.sleep(utils.transitionDuration)
+ await vm.$nextTick()
+ const alert = document.querySelector('.alert')
+ expect(alert).to.exist
+ expect(alert.style.top).to.equal(OFFSET)
+ expect(alert.style.right).to.equal(OFFSET)
+ expect(alert.style.bottom).to.equal('')
+ expect(alert.style.left).to.equal('')
+ alert.querySelector('button.close').click()
+ await utils.sleep(utils.transitionDuration)
+ await vm.$nextTick()
+ expect(document.querySelector('.alert')).not.exist
+ })
+
+ it('should be able to use `placement=bottom-right` notification', async () => {
+ const _vm = vm.$refs['notification-placements']
+ await vm.$nextTick()
+ const trigger = _vm.$el.querySelectorAll('.btn')[1]
+ trigger.click()
+ await utils.sleep(utils.transitionDuration)
+ await vm.$nextTick()
+ const alert = document.querySelector('.alert')
+ expect(alert).to.exist
+ expect(alert.style.top).to.equal('')
+ expect(alert.style.right).to.equal(OFFSET)
+ expect(alert.style.bottom).to.equal(OFFSET)
+ expect(alert.style.left).to.equal('')
+ alert.querySelector('button.close').click()
+ await utils.sleep(utils.transitionDuration)
+ await vm.$nextTick()
+ expect(document.querySelector('.alert')).not.exist
+ })
+
+ it('should be able to use `placement=bottom-left` notification', async () => {
+ const _vm = vm.$refs['notification-placements']
+ await vm.$nextTick()
+ const trigger = _vm.$el.querySelectorAll('.btn')[2]
+ trigger.click()
+ await utils.sleep(utils.transitionDuration)
+ await vm.$nextTick()
+ const alert = document.querySelector('.alert')
+ expect(alert).to.exist
+ expect(alert.style.top).to.equal('')
+ expect(alert.style.right).to.equal('')
+ expect(alert.style.bottom).to.equal(OFFSET)
+ expect(alert.style.left).to.equal(OFFSET)
+ alert.querySelector('button.close').click()
+ await utils.sleep(utils.transitionDuration)
+ await vm.$nextTick()
+ expect(document.querySelector('.alert')).not.exist
+ })
+
+ it('should be able to use `placement=top-left` notification', async () => {
+ const _vm = vm.$refs['notification-placements']
+ await vm.$nextTick()
+ const trigger = _vm.$el.querySelectorAll('.btn')[3]
+ trigger.click()
+ await utils.sleep(utils.transitionDuration)
+ await vm.$nextTick()
+ const alert = document.querySelector('.alert')
+ expect(alert).to.exist
+ expect(alert.style.top).to.equal(OFFSET)
+ expect(alert.style.right).to.equal('')
+ expect(alert.style.bottom).to.equal('')
+ expect(alert.style.left).to.equal(OFFSET)
+ alert.querySelector('button.close').click()
+ await utils.sleep(utils.transitionDuration)
+ await vm.$nextTick()
+ expect(document.querySelector('.alert')).not.exist
+ })
+
+ it('should be able to use `dismissible=false` notification', async () => {
+ const _vm = vm.$refs['notification-without-dismiss-btn']
+ await vm.$nextTick()
+ const trigger = _vm.$el.querySelectorAll('.btn')[0]
+ trigger.click()
+ await vm.$nextTick()
+ trigger.click()
+ await utils.sleep(utils.transitionDuration)
+ await vm.$nextTick()
+ const alert = document.querySelectorAll('.alert')
+ expect(alert.length).to.equal(2)
+ expect(alert[0].querySelector('button.close')).not.exist
+ expect(alert[1].querySelector('button.close')).not.exist
+ await utils.sleep(5200)
+ await vm.$nextTick()
+ expect(document.querySelector('.alert')).not.exist
+ }).timeout(5000 + 2000)
+
+ it('should be able to use without options and callback', async () => {
+ Notification.notify(undefined)
+ await utils.sleep(utils.transitionDuration)
+ const alert = document.querySelector('.alert')
+ expect(alert).to.exist
+ alert.querySelector('button.close').click()
+ await utils.sleep(utils.transitionDuration)
+ expect(document.querySelector('.alert')).not.exist
+ })
+
+ it('should be able to use without Promise', async () => {
+ // mute Promise
+ const savedPromise = window.Promise
+ window.Promise = null
+ // alert
+ Notification.notify({title: 'test'})
+ // restore Promise
+ window.Promise = savedPromise
+ await utils.sleep(utils.transitionDuration)
+ const alert = document.querySelector('.alert')
+ expect(alert).to.exist
+ alert.querySelector('button.close').click()
+ await utils.sleep(utils.transitionDuration)
+ expect(document.querySelector('.alert')).not.exist
+ })
+
+ it('should be able to avoid invalid placement', async () => {
+ Notification.notify({placement: 'top-bottom'}) // invalid
+ await utils.sleep(utils.transitionDuration)
+ expect(document.querySelector('.alert')).not.exist
+ })
+
+ it('should be able to use custom icon', async () => {
+ Notification.notify({title: 'test', icon: 'fa fa-check'})
+ await utils.sleep(utils.transitionDuration)
+ const alert = document.querySelector('.alert')
+ expect(alert).to.exist
+ expect(alert.querySelectorAll('.media-left > .fa').length).to.equal(1)
+ expect(alert.querySelectorAll('.media-left > .fa-check')).to.exist
+ alert.querySelector('button.close').click()
+ await utils.sleep(utils.transitionDuration)
+ expect(document.querySelector('.alert')).not.exist
+ })
+
+ it('should be able to disable icon with types', async () => {
+ Notification.notify({title: 'test', icon: '', type: 'danger'})
+ await utils.sleep(utils.transitionDuration)
+ const alert = document.querySelector('.alert')
+ expect(alert).to.exist
+ expect(alert.querySelector('.media-left')).not.exist
+ alert.querySelector('button.close').click()
+ await utils.sleep(utils.transitionDuration)
+ expect(document.querySelector('.alert')).not.exist
+ })
+})