diff --git a/docs/.vuepress/clientAppEnhance.ts b/docs/.vuepress/clientAppEnhance.ts index edbf428bb1..7ce87be11b 100644 --- a/docs/.vuepress/clientAppEnhance.ts +++ b/docs/.vuepress/clientAppEnhance.ts @@ -3,7 +3,7 @@ import Kongponents from '../../src' // Import component-specific files import * as icons from '../../src/components/KIcon/icons' // KIcon icons -// import ToastManager from '../../src/components/KToaster/ToastManager' +import ToastManager from '../../src/components/KToaster/ToastManager' // Import global VuePress components import ColorSwatch from './components/ColorSwatch.vue' @@ -14,7 +14,7 @@ export default defineClientAppEnhance(({ app, router, siteData }) => { app.config.globalProperties.$icons = Object.keys(icons) // Register ToastManager - // app.config.globalProperties.$toaster = new ToastManager() + app.config.globalProperties.$toaster = new ToastManager() // Register all Kongponents app.use(Kongponents) diff --git a/docs/.vuepress/config.ts b/docs/.vuepress/config.ts index 630d285371..4b258a6050 100644 --- a/docs/.vuepress/config.ts +++ b/docs/.vuepress/config.ts @@ -92,6 +92,7 @@ export default defineUserConfig({ '/components/label', '/components/modal', '/components/popover', + '/components/toaster', '/components/tooltip', ] }, diff --git a/docs/components/toaster.md b/docs/components/toaster.md new file mode 100644 index 0000000000..cf4323ce1b --- /dev/null +++ b/docs/components/toaster.md @@ -0,0 +1,190 @@ +# Toaster + +**KToaster** - a popup notification typically used to show the result of an action. The toaster can close on its own but can also be manually dismissed. + +KToaster is used via the a `ToastManager` instance. All rendering is controlled from ToastManager via an intuitive, imperative api. It is recommended that you initialize it as a singleton in your app such as `this.$toaster`. + +```js +import Vue from 'vue'; +import { ToastManager } from '@kongponents/ktoaster'; + +// optional singleton to allow any part of your app access ToastManager +Vue.prototype.$toaster = new ToastManager() +``` + +Once `ToastManager` is registered as a singleton, you can access it's methods via `this.$toaster` e.g.: + +Open Toaster + +```vue +Open Toaster +``` + +## Arguments + +### message + +The default argument passed to the toaster is the message. + +Open Toaster + +```vue +Open Toaster +``` + +### appearance + +The Toaster uses the same appearance values as [KAlert](/components/alert) and are applied the same way. + +Open Toaster +Open Toaster +Open Toaster +Open Toaster + +```vue + + + +``` + +### timeout + +The default timeout is 5000ms (5 seconds) however you can change this to by passing an override argument. + +- `timeoutMilliseconds` + +{{timeLeft > 3 ? 'Open Toaster' : `Closing in ${timeLeft} seconds` }} + +```vue + + + +``` + +## Toaster State + +You can view the current state of active toasters by calling `this.$toaster.toasters`. Click the buttons below to watch the state change + +Open Toaster +Open Toaster +Open Toaster + +
+
+{{ JSON.stringify(toasters || [], null, 2) }}
+
+
+ +```vue + + +``` + +## Variations + +### Long Content + +
+Prose + +Raw error message + + + + diff --git a/src/components/KToaster/KToaster.spec.ts b/src/components/KToaster/KToaster.spec.ts new file mode 100644 index 0000000000..ee703be970 --- /dev/null +++ b/src/components/KToaster/KToaster.spec.ts @@ -0,0 +1,90 @@ +import { mount } from '@cypress/vue' +import KToaster from '@/components/KToaster/KToaster.vue' +import ToastManager from '@/components/KToaster/ToastManager' + +describe('KToaster', () => { + // describe('KToaster.vue', () => { + // it('renders toaster', () => { + // const wrapper = mount(KToaster) + + // expect(wrapper.findAll('.toaster-container-outer span')).toHaveLength(0) + + // wrapper.vm.toasterState.push({ message: 'hey toasty' }) + // wrapper.vm.toasterState.push({ appearance: 'success', message: 'hey toasty' }) + // wrapper.vm.toasterState.push({ appearance: 'danger', message: 'hey toasty' }) + // wrapper.vm.toasterState.push({ appearance: 'danger', message: 'hey toasty' }) + + // expect(wrapper.findAll('div[role="alert"].success')).toHaveLength(1) + // expect(wrapper.findAll('div[role="alert"].danger')).toHaveLength(2) + // expect(wrapper.findAll('.toaster-container-outer div.k-alert-msg')).toHaveLength(4) + + // expect(wrapper).toMatchSnapshot() + // }) + // }) + + // describe('ToastManager', () => { + // it('opens toasters', () => { + // const tm = new ToastManager() + + // tm.open('hey toasty') + // tm.open({message: 'yo toasty'}) + // tm.open({key: 2, message: 'there has been an alert'}) + // expect(tm.toasters).toHaveLength(3) + // }) + + // it('opens toasters - invalid appearance', () => { + // const tm = new ToastManager() + + // tm.open({message: 'yo', appearance: 'ugly'}) + // expect(tm.toasters).toHaveLength(1) + // expect(tm.toasters[0].appearance).toBe('info') + // }) + + // it('dismisses toasters after default timeout', () => { + // const tm = new ToastManager() + + // tm.open('hey toasty') + // tm.open('hey toasty') + // expect(tm.toasters).toHaveLength(2) + // jest.advanceTimersByTime(4999) + // expect(tm.toasters).toHaveLength(2) + // jest.advanceTimersByTime(1) + // expect(tm.toasters).toHaveLength(0) + // }) + + // it('dismisses toasters after timeout per toast', () => { + // const tm = new ToastManager() + + // tm.open({ message: 'hey toasty', timeoutMilliseconds: 1000 }) + // tm.open({ message: 'hey toasty', timeoutMilliseconds: 2000 }) + // tm.open({ message: 'hey toasty', timeoutMilliseconds: 3000 }) + // tm.open({ message: 'hey toasty' }) // default 5000 milliseconds + + // expect(tm.toasters).toHaveLength(4) + // jest.advanceTimersByTime(1000) + // expect(tm.toasters).toHaveLength(3) + // jest.advanceTimersByTime(1000) + // expect(tm.toasters).toHaveLength(2) + // jest.advanceTimersByTime(1000) + // expect(tm.toasters).toHaveLength(1) + // jest.advanceTimersByTime(1000) + // expect(tm.toasters).toHaveLength(1) + // jest.advanceTimersByTime(1000) + // expect(tm.toasters).toHaveLength(0) + // }) + + // it('closes toasters', () => { + // const tm = new ToastManager() + + // tm.open({ key: '#123', message: 'hey toasty' }) + // tm.open({ key: '#345', message: 'hey toasty' }) + + // expect(tm.toasters).toHaveLength(2) + + // tm.close('#345') + + // expect(tm.toasters).toHaveLength(1) + // expect(tm.toasters[0].key).toBe('#123') + // }) + // }) +}) diff --git a/src/components/KToaster/KToaster.vue b/src/components/KToaster/KToaster.vue new file mode 100644 index 0000000000..d5385250fc --- /dev/null +++ b/src/components/KToaster/KToaster.vue @@ -0,0 +1,124 @@ + + + + + + + diff --git a/src/components/KToaster/ToastManager.ts b/src/components/KToaster/ToastManager.ts new file mode 100644 index 0000000000..21598a1a7f --- /dev/null +++ b/src/components/KToaster/ToastManager.ts @@ -0,0 +1,71 @@ +import KToaster, { toasterAppearances, Toast } from './KToaster.vue' +import { createApp, h, ref, Ref } from 'vue' + +const APPEARANCES = Object.keys(toasterAppearances) + +const DEFAULTS = { + id: 'toaster-container', + timeout: 5000, + appearance: toasterAppearances.info, +} + +export default class ToastManager { + public toasters: Ref + public timeout: number + public appearance: string + public id: string + + constructor(id = DEFAULTS.id, timeout = DEFAULTS.timeout, appearance = DEFAULTS.appearance) { + this.toasters = ref([]) + + this.timeout = timeout + this.appearance = appearance + this.id = id + + this.mount() + } + + mount() { + // Create a component container for the notification to bind to + const notificationContainer = document.createElement('div') + notificationContainer.id = this.id + document.body.appendChild(notificationContainer) + + const Toast = h(KToaster, { + toasterState: this.toasters.value, + onClose: (key: any) => this.close(key), + }) + + createApp(Toast).mount(`#${this.id}`) + } + + setTimer(key, timeout) { + return setTimeout(() => this.close(key), timeout) + } + + open(args) { + const { key, timeoutMilliseconds, appearance, message } = args + + const _key = key || (this.toasters.value.length) + new Date().getTime() + const _appearance = (appearance && APPEARANCES.indexOf(appearance) !== -1) + ? appearance + : this.appearance + const timer = this.setTimer(_key, timeoutMilliseconds || this.timeout) + + // Add toaster to state + this.toasters.value.push({ + key: _key, + appearance: _appearance, + message: message || args, + timer: timer, + timeoutMilliseconds: timeoutMilliseconds || this.timeout, + }) + } + + close(key) { + const i = this.toasters.value.findIndex(n => key === n.key) + + clearTimeout(this.toasters.value[i].timer) + this.toasters.value.splice(i, 1) + } +} diff --git a/src/components/index.ts b/src/components/index.ts index b1628a8d66..7338c9dfc1 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -20,3 +20,4 @@ export { default as KBreadcrumbs } from './KBreadcrumbs/KBreadcrumbs.vue' export { default as Krumbs } from './Krumbs' // KBreadcrumbs alias (backwards-compatible with console warning) export { default as KBadge } from './KBadge/KBadge.vue' export { default as KClipboardProvider } from './KClipboardProvider' +export { default as KToaster } from './KToaster/KToaster.vue'