diff --git a/.eslintignore b/.eslintignore index 654b922..4ebe047 100644 --- a/.eslintignore +++ b/.eslintignore @@ -5,4 +5,3 @@ dist/ coverage/ .github/ .vscode/ -demo/ diff --git a/.eslintrc.js b/.eslintrc.js index 760c487..8a7e15a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -6,27 +6,65 @@ module.exports = { "vue/setup-compiler-macros": true, }, extends: [ - "plugin:vue/vue3-recommended", "eslint:recommended", - "plugin:import/errors", - "plugin:import/warnings", - "plugin:import/typescript", - "plugin:@typescript-eslint/eslint-recommended", + "plugin:vue/vue3-recommended", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended", + "plugin:import/recommended", + "plugin:import/typescript", ], parser: "vue-eslint-parser", parserOptions: { parser: "@typescript-eslint/parser", sourceType: "module", }, - plugins: ["vue", "@typescript-eslint"], + plugins: ["vue", "@typescript-eslint", "jsx-a11y"], rules: { - "@typescript-eslint/no-use-before-define": "off", - "@typescript-eslint/explicit-module-boundary-types": "off", "no-console": process.env.NODE_ENV === "production" ? "error" : "off", "no-debugger": process.env.NODE_ENV === "production" ? "error" : "off", - "no-useless-escape": "off", + "@typescript-eslint/no-empty-function": ["off"], + "import/order": [ + "error", + { + groups: [ + "builtin", + "external", + "internal", + "parent", + "object", + "type", + "sibling", + "index", + ], + warnOnUnassignedImports: true, + pathGroups: [ + { + pattern: "vue", + group: "builtin", + position: "before", + }, + { + pattern: "**/*.{css,scss}", + group: "index", + position: "after", + }, + ], + pathGroupsExcludedImportTypes: ["builtin", "vue", "**/*.{css,scss}"], + "newlines-between": "always", + alphabetize: { + order: "asc", + caseInsensitive: true, + }, + }, + ], + "import/first": "error", + "import/no-duplicates": "error", + "import/newline-after-import": "error", + "import/no-unassigned-import": [ + "error", + { allow: ["**/*.css", "**/*.scss", "**/*.sass"] }, + ], + "import/no-named-default": "error", }, overrides: [ { @@ -39,4 +77,9 @@ module.exports = { }, }, ], + settings: { + "import/parsers": { + "@typescript-eslint/parser": [".ts", ".tsx"], + }, + }, } diff --git a/demo/src/App.vue b/demo/src/App.vue index 5e10d72..6b49258 100644 --- a/demo/src/App.vue +++ b/demo/src/App.vue @@ -1,12 +1,27 @@ diff --git a/demo/src/main.ts b/demo/src/main.ts index 8f13e72..7aa6ee4 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -1,5 +1,7 @@ -import { createApp } from 'vue' -import App from './App.vue' +import { createApp } from "vue" + import Toast from "../../src" -createApp(App).use(Toast).mount('#app') +import App from "./App.vue" + +createApp(App).use(Toast).mount("#app") diff --git a/package.json b/package.json index d7e4b41..5a0700e 100644 --- a/package.json +++ b/package.json @@ -8,18 +8,18 @@ "dev": "yarn vite --mode demo", "prebuild": "rimraf ./dist", "build": "yarn build:code && yarn build:tsc", - "build:code": "vite build --mode lib", - "build:tsc": "vue-tsc --emitDeclarationOnly --project tsconfig.build.json", - "build:demo": "vite build --mode demo", + "build:code": "MODE=lib vite build", + "build:tsc": "NODE_ENV=production tsc --emitDeclarationOnly --project tsconfig.build.json", + "build:demo": "MODE=demo vite build", "test:unit": "jest", "test": "yarn test:unit", "test:watch": "yarn test --watch", - "lint": "yarn lint:tsc && yarn lint:eslint", - "lint:fix": "yarn lint:tsc && yarn lint:eslint:fix", - "lint:tsc": "vue-tsc --noEmit", - "lint:eslint": "eslint --ext .vue,.ts .", - "lint:eslint:fix": "yarn lint:eslint --fix", - "preview": "vite preview --port 3000 demo", + "lint": "yarn lint:tsc && yarn lint:eslint .", + "lint:fix": "yarn lint --fix", + "lint:tsc": "NODE_ENV=production vue-tsc --noEmit", + "lint:eslint": "NODE_ENV=production eslint --ext vue,ts,tsx", + "lint:staged": "yarn lint:tsc && yarn lint:eslint --fix", + "preview": "NODE_ENV=production vite preview --port 3000 demo", "prepublishOnly": "yarn lint && yarn test && yarn build", "prepare": "husky install" }, @@ -52,6 +52,7 @@ "eslint": "^8.8.0", "eslint-config-prettier": "^8.3.0", "eslint-plugin-import": "^2.25.4", + "eslint-plugin-jsx-a11y": "^6.5.1", "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-vue": "^8.4.0", "husky": "^7.0.4", @@ -68,7 +69,7 @@ }, "lint-staged": { "*.{js,jsx,vue,ts,tsx}": [ - "eslint --ext .vue,.ts --fix" + "yarn lint:staged" ] }, "peerDependencies": { diff --git a/src/components/VtCloseButton.vue b/src/components/VtCloseButton.vue index a12fac1..c22b399 100644 --- a/src/components/VtCloseButton.vue +++ b/src/components/VtCloseButton.vue @@ -9,34 +9,27 @@ - - - - - - + + diff --git a/src/components/VtTransition.vue b/src/components/VtTransition.vue index 2a3ab91..f3c445d 100644 --- a/src/components/VtTransition.vue +++ b/src/components/VtTransition.vue @@ -14,27 +14,22 @@ - - diff --git a/src/components/icons/VtInfoIcon.vue b/src/components/icons/VtInfoIcon.vue index f10395b..f5e92dd 100644 --- a/src/components/icons/VtInfoIcon.vue +++ b/src/components/icons/VtInfoIcon.vue @@ -2,9 +2,6 @@ - diff --git a/src/components/icons/VtSuccessIcon.vue b/src/components/icons/VtSuccessIcon.vue index 04199ac..03a1a5c 100644 --- a/src/components/icons/VtSuccessIcon.vue +++ b/src/components/icons/VtSuccessIcon.vue @@ -2,9 +2,6 @@ - diff --git a/src/components/icons/VtWarningIcon.vue b/src/components/icons/VtWarningIcon.vue index 51142ba..4de5aad 100644 --- a/src/components/icons/VtWarningIcon.vue +++ b/src/components/icons/VtWarningIcon.vue @@ -2,9 +2,6 @@ - diff --git a/src/index.ts b/src/index.ts index 451cec7..f051a6c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,86 +1,25 @@ -import { Plugin, InjectionKey, provide, inject, getCurrentInstance } from "vue" -import { buildInterface } from "./ts/interface" import type { ToastInterface } from "./ts/interface" -import { POSITION, TYPE, VT_NAMESPACE } from "./ts/constants" -import { EventBusInterface, isEventBusInterface, EventBus } from "./ts/eventBus" -import type { PluginOptions } from "./types" -import * as ownExports from "./index" -import "./scss/index.scss" -import { isBrowser } from "./ts/utils" - -const createMockToastInterface = (): ToastInterface => { - const toast = () => - console.warn(`[${VT_NAMESPACE}] This plugin does not support SSR!`) - return new Proxy(toast, { - get() { - return toast - }, - }) as unknown as ToastInterface -} - -function createToastInterface(eventBus: EventBusInterface): ToastInterface -function createToastInterface(options?: PluginOptions): ToastInterface -function createToastInterface( - optionsOrEventBus?: PluginOptions | EventBusInterface -): ToastInterface { - if (!isBrowser()) { - return createMockToastInterface() - } - if (isEventBusInterface(optionsOrEventBus)) { - return buildInterface({ eventBus: optionsOrEventBus }, false) - } - return buildInterface(optionsOrEventBus, true) -} - -const toastInjectionKey: InjectionKey = - Symbol("VueToastification") - -const globalEventBus = new EventBus() +import type { PluginOptions } from "./types/plugin" -const VueToastificationPlugin: Plugin = (App, options?: PluginOptions) => { - if (options?.shareAppContext === true) { - options.shareAppContext = App - } - const inter = ownExports.createToastInterface({ - eventBus: globalEventBus, - ...options, - }) - App.provide(toastInjectionKey, inter) -} - -const provideToast = (options?: PluginOptions) => { - const toast = ownExports.createToastInterface(options) - if (getCurrentInstance()) { - provide(toastInjectionKey, toast) - } -} - -const useToast = (eventBus?: EventBus) => { - if (eventBus) { - return ownExports.createToastInterface(eventBus) - } - const toast = getCurrentInstance() - ? inject(toastInjectionKey, undefined) - : undefined - return toast ? toast : ownExports.createToastInterface(globalEventBus) -} +import "./scss/index.scss" +import { + createToastInstance, + provideToast, + useToast, +} from "./ts/composables/useToast" +import { POSITION, TYPE } from "./ts/constants" +import { EventBus } from "./ts/eventBus" +import { VueToastificationPlugin } from "./ts/plugin" export default VueToastificationPlugin export { - // Types - ToastInterface, - PluginOptions, - // Consts - POSITION, - TYPE, - toastInjectionKey, - // Functions - createToastInterface, - useToast, + createToastInstance, provideToast, - // Classes + useToast, EventBus, - // Instances - globalEventBus, + POSITION, + TYPE, + PluginOptions, + ToastInterface, } diff --git a/src/ts/composables/useDraggable.ts b/src/ts/composables/useDraggable.ts index 0724441..cd11abf 100644 --- a/src/ts/composables/useDraggable.ts +++ b/src/ts/composables/useDraggable.ts @@ -7,12 +7,14 @@ import { computed, watch, } from "vue" -import { ToastOptions } from "../../types" + import { isDOMRect, getX, getY } from "../utils" +import type { Draggable } from "../../types/common" + export const useDraggable = ( el: Ref, - props: Required> + props: Required ) => { // Extract used props const { draggablePercent, draggable } = toRefs(props) diff --git a/src/ts/composables/useFocusable.ts b/src/ts/composables/useFocusable.ts index 84306d0..82ca31e 100644 --- a/src/ts/composables/useFocusable.ts +++ b/src/ts/composables/useFocusable.ts @@ -1,9 +1,10 @@ import { toRefs, ref, Ref, onMounted, onBeforeUnmount } from "vue" -import { ToastOptions } from "../../types" + +import type { Focusable } from "../../types/common" export const useFocusable = ( el: Ref, - props: Required> + props: Required ) => { const { pauseOnFocusLoss } = toRefs(props) const focused = ref(true) diff --git a/src/ts/composables/useHoverable.ts b/src/ts/composables/useHoverable.ts index 3004164..ba3cb0b 100644 --- a/src/ts/composables/useHoverable.ts +++ b/src/ts/composables/useHoverable.ts @@ -1,9 +1,10 @@ import { toRefs, ref, Ref, onMounted, onBeforeUnmount } from "vue" -import { ToastOptions } from "../../types" + +import type { Hoverable } from "../../types/common" export const useHoverable = ( el: Ref, - props: Required> + props: Required ) => { const { pauseOnHover } = toRefs(props) const hovering = ref(false) diff --git a/src/ts/composables/useToast.ts b/src/ts/composables/useToast.ts new file mode 100644 index 0000000..5e033d1 --- /dev/null +++ b/src/ts/composables/useToast.ts @@ -0,0 +1,74 @@ +import { InjectionKey, provide, inject, getCurrentInstance } from "vue" + +import { VT_NAMESPACE } from "../constants" +import { + EventBusInterface, + isEventBusInterface, + EventBus, + globalEventBus, +} from "../eventBus" +import { buildInterface } from "../interface" +import { isBrowser } from "../utils" + +import type { PluginOptions } from "../../types/plugin" +import type { ToastInterface } from "../interface" + +import { createToastInstance as ownExports_createToastInstance } from "./useToast" + +const toastInjectionKey: InjectionKey = + Symbol("VueToastification") + +/** + * Creates (or recovers) a toast instance and returns + * an interface to it + */ +interface CreateToastInstance { + /** + * Creates an interface to an existing instance from its interface + */ + (eventBus: EventBusInterface): ToastInterface + /** + * Creats a new instance of Vue Toastification + */ + (options?: PluginOptions): ToastInterface +} + +const createMockToastInstance: CreateToastInstance = () => { + const toast = () => + // eslint-disable-next-line no-console + console.warn(`[${VT_NAMESPACE}] This plugin does not support SSR!`) + return new Proxy(toast, { + get() { + return toast + }, + }) as unknown as ToastInterface +} + +const createToastInstance: CreateToastInstance = optionsOrEventBus => { + if (!isBrowser()) { + return createMockToastInstance() + } + if (isEventBusInterface(optionsOrEventBus)) { + return buildInterface({ eventBus: optionsOrEventBus }, false) + } + return buildInterface(optionsOrEventBus, true) +} + +const provideToast = (options?: PluginOptions) => { + if (getCurrentInstance()) { + const toast = ownExports_createToastInstance(options) + provide(toastInjectionKey, toast) + } +} + +const useToast = (eventBus?: EventBus) => { + if (eventBus) { + return ownExports_createToastInstance(eventBus) + } + const toast = getCurrentInstance() + ? inject(toastInjectionKey, undefined) + : undefined + return toast ? toast : ownExports_createToastInstance(globalEventBus) +} + +export { useToast, provideToast, toastInjectionKey, createToastInstance } diff --git a/src/ts/eventBus.ts b/src/ts/eventBus.ts index fc6ed66..5f6120b 100644 --- a/src/ts/eventBus.ts +++ b/src/ts/eventBus.ts @@ -1,15 +1,16 @@ -import type { EVENTS } from "./constants" -import { hasProp, isFunction } from "./utils" +import type { ToastID } from "../types/common" import type { - ToastOptionsAndRequiredContent, - ToastID, + ToastOptionsAndContent, ToastContent, ToastOptions, - PluginOptions, -} from "../types/index" +} from "../types/toast" +import type { ToastContainerOptions } from "../types/toastContainer" +import type { EVENTS } from "./constants" + +import { hasProp, isFunction } from "./utils" type EventData = { - [EVENTS.ADD]: ToastOptionsAndRequiredContent & { + [EVENTS.ADD]: ToastOptionsAndContent & { id: ToastID } [EVENTS.CLEAR]: undefined @@ -25,7 +26,7 @@ type EventData = { options: Partial & { content: ToastContent } create: true } - [EVENTS.UPDATE_DEFAULTS]: PluginOptions + [EVENTS.UPDATE_DEFAULTS]: ToastContainerOptions } type Handler = (event: EventData[E]) => void @@ -66,3 +67,5 @@ export class EventBus implements EventBusInterface { export const isEventBusInterface = (e: unknown): e is EventBusInterface => ["on", "off", "emit"].every(f => hasProp(e, f) && isFunction(e[f])) + +export const globalEventBus = new EventBus() diff --git a/src/ts/interface.ts b/src/ts/interface.ts index 004a7e5..35202cb 100644 --- a/src/ts/interface.ts +++ b/src/ts/interface.ts @@ -1,139 +1,210 @@ import { createApp, nextTick } from "vue" -import { EventBus } from "./eventBus" + import ToastContainer from "../components/VtToastContainer.vue" -import { + +import type { ToastID } from "../types/common" +import type { BasePluginOptions, PluginOptions } from "../types/plugin" +import type { ToastContent, ToastOptions, - ToastID, - PluginOptions, - ToastOptionsAndRequiredContent, -} from "../types" -import { TYPE, EVENTS, VT_NAMESPACE } from "./constants" -import { getId, isUndefined } from "./utils" + ToastOptionsAndContent, +} from "../types/toast" -export const buildInterface = ( - globalOptions: PluginOptions = {}, - mountContainer = true -) => { - const events = (globalOptions.eventBus = - globalOptions.eventBus || new EventBus()) - if (mountContainer) { - nextTick(() => { - const app = createApp(ToastContainer, { - ...globalOptions, - }) - const component = app.mount(document.createElement("div")) - - const onMounted = globalOptions.onMounted - if (!isUndefined(onMounted)) { - onMounted(component, app) - } - - if (globalOptions.shareAppContext) { - const baseApp = globalOptions.shareAppContext - if (baseApp === true) { - console.warn( - `[${VT_NAMESPACE}] App to share context with was not provided.` - ) - } else { - app._context.components = baseApp._context.components - app._context.directives = baseApp._context.directives - app._context.mixins = baseApp._context.mixins - app._context.provides = baseApp._context.provides - app.config.globalProperties = baseApp.config.globalProperties - } - } - }) - } - /** - * Display a toast - */ - const toast = (content: ToastContent, options?: ToastOptions): ToastID => { - const props: ToastOptionsAndRequiredContent & { - id: ToastID - } = Object.assign({}, { id: getId(), type: TYPE.DEFAULT }, options, { - content, - }) - events.emit(EVENTS.ADD, props) - return props.id - } +import { TYPE, EVENTS } from "./constants" +import { EventBus, EventBusInterface } from "./eventBus" +import { asContainerProps, getId, isUndefined } from "./utils" + +/** + * Display a toast + */ +interface ToastMethod { /** - * Clear all toasts + * @param content Toast content. + * + * Can be a string, JSX or a custom component passed directly + * + * To provide props and listeners to the custom component, you + * do so by providing an object with the following shape: + * + * ```ts + * { + * component: JSX | VueComponent + * props: Record + * listeners: Record + * } + * ``` + * + * for more details, see https://github.com/Maronato/vue-toastification#toast-content-object + * + * @param options Toast configuration + * + * For details, see: https://github.com/Maronato/vue-toastification#toast-options-object + * + * @returns ID of the created toast */ - toast.clear = () => events.emit(EVENTS.CLEAR, undefined) + (content: ToastContent, options?: ToastOptions & { type?: T }): ToastID +} + +interface DismissToast { /** - * Update Plugin Defaults + * @param toastID ID of the toast to be dismissed */ - toast.updateDefaults = (update: PluginOptions) => { - events.emit(EVENTS.UPDATE_DEFAULTS, update) - } + (toastID: ToastID): void +} + +interface ClearToasts { + (): void +} + +interface UpdateDefaults { /** - * Dismiss toast specified by an id + * @param update Plugin options to update + * + * Accepts all* options provided during plugin + * registration and updates them. + * + * For details, see https://github.com/Maronato/vue-toastification#updating-default-options */ - toast.dismiss = (id: ToastID) => { - events.emit(EVENTS.DISMISS, id) - } + (update: BasePluginOptions): void +} + +interface UpdateToast { /** - * Update Toast + * @param toastID ID of the toast to update + * @param update Object that may contain the content to update, or the options to merge + * @param create If set to false, this method only updates existing toasts and does + * nothing if the provided `toastID` does not exist */ - function updateToast( - id: ToastID, - { content, options }: { content?: ToastContent; options?: ToastOptions }, + ( + toastID: ToastID, + update: { content?: ToastContent; options?: ToastOptions }, create?: false ): void - function updateToast( - id: ToastID, - { content, options }: { content: ToastContent; options?: ToastOptions }, - create?: true + /** + * @param toastID ID of the toast to create / update + * @param update Object that must contain the toast content and may contain the options to merge + * @param create If set to true, this method updates existing toasts or creates new toasts if + * the provided `toastID` does not exist + */ + ( + toastID: ToastID, + update: { content: ToastContent; options?: ToastOptions }, + create: true ): void - function updateToast( - id: ToastID, - { content, options }: { content?: ToastContent; options?: ToastOptions }, - create = false - ): void { - const opt = Object.assign({}, options, { content }) as ToastOptions & { - content: ToastContent - } - events.emit(EVENTS.UPDATE, { - id, - options: opt, - create, - }) - } - toast.update = updateToast +} + +export interface ToastInterface extends ToastMethod { /** * Display a success toast */ - toast.success = ( - content: ToastContent, - options?: ToastOptions & { type?: TYPE.SUCCESS } - ) => toast(content, Object.assign({}, options, { type: TYPE.SUCCESS })) - + success: ToastMethod /** * Display an info toast */ - toast.info = ( - content: ToastContent, - options?: ToastOptions & { type?: TYPE.INFO } - ) => toast(content, Object.assign({}, options, { type: TYPE.INFO })) - + info: ToastMethod + /** + * Display a warning toast + */ + warning: ToastMethod /** * Display an error toast */ - toast.error = ( - content: ToastContent, - options?: ToastOptions & { type?: TYPE.ERROR } - ) => toast(content, Object.assign({}, options, { type: TYPE.ERROR })) - + error: ToastMethod /** - * Display a warning toast + * Dismiss toast specified by an id */ - toast.warning = ( - content: ToastContent, - options?: ToastOptions & { type?: TYPE.WARNING } - ) => toast(content, Object.assign({}, options, { type: TYPE.WARNING })) + dismiss: DismissToast + /** + * Update Toast + */ + update: UpdateToast + /** + * Clear all toasts + */ + clear: ClearToasts + /** + * Update Plugin Defaults + */ + updateDefaults: UpdateDefaults +} + +/** + * Creates and mounts the plugin app + * @param options Plugin options passed during init + */ +function mountPlugin(options: PluginOptions) { + const { shareAppContext, onMounted, ...basePluginOptions } = options + + const containerProps = asContainerProps(basePluginOptions) + + const app = createApp(ToastContainer, { + ...containerProps, + }) + + if (shareAppContext && shareAppContext !== true) { + const userApp = shareAppContext + app._context.components = userApp._context.components + app._context.directives = userApp._context.directives + app._context.mixins = userApp._context.mixins + app._context.provides = userApp._context.provides + app.config.globalProperties = userApp.config.globalProperties + } + + const component = app.mount(document.createElement("div")) - return toast + if (!isUndefined(onMounted)) { + onMounted(component, app) + } } -export type ToastInterface = ReturnType +const createInterface = (events: EventBusInterface): ToastInterface => { + const createToastMethod = ( + type: T + ): ToastMethod => { + const method: ToastMethod = (content, options) => { + const props: ToastOptionsAndContent & { + id: ToastID + } = Object.assign({ id: getId(), type, content }, options) + events.emit(EVENTS.ADD, props) + return props.id + } + return method + } + + const dismiss: DismissToast = toastID => events.emit(EVENTS.DISMISS, toastID) + const clear: ClearToasts = () => events.emit(EVENTS.CLEAR, undefined) + const updateDefaults: UpdateDefaults = update => + events.emit(EVENTS.UPDATE_DEFAULTS, asContainerProps(update)) + const update: UpdateToast = (toastID, update, create) => { + const { content, options } = update + events.emit(EVENTS.UPDATE, { + id: toastID, + create: create || false, + options: { ...options, content: content as ToastContent }, + }) + } + + return Object.assign(createToastMethod(TYPE.DEFAULT), { + success: createToastMethod(TYPE.SUCCESS), + info: createToastMethod(TYPE.INFO), + warning: createToastMethod(TYPE.WARNING), + error: createToastMethod(TYPE.ERROR), + dismiss, + clear, + update, + updateDefaults, + }) +} + +export const buildInterface = ( + globalOptions: PluginOptions = {}, + mountContainer = true +): ToastInterface => { + const options = { ...globalOptions } + const events = (options.eventBus = options.eventBus || new EventBus()) + + if (mountContainer) { + nextTick(() => mountPlugin(options)) + } + return createInterface(events) +} diff --git a/src/ts/plugin.ts b/src/ts/plugin.ts new file mode 100644 index 0000000..8ab5302 --- /dev/null +++ b/src/ts/plugin.ts @@ -0,0 +1,20 @@ +import { Plugin } from "vue" + +import type { PluginOptions } from "../types/plugin" + +import { createToastInstance, toastInjectionKey } from "./composables/useToast" +import { globalEventBus } from "./eventBus" + +export const VueToastificationPlugin: Plugin = ( + App, + options?: PluginOptions +) => { + if (options?.shareAppContext === true) { + options.shareAppContext = App + } + const inter = createToastInstance({ + eventBus: globalEventBus, + ...options, + }) + App.provide(toastInjectionKey, inter) +} diff --git a/src/ts/propValidators.ts b/src/ts/propValidators.ts index 40e422c..7f991eb 100644 --- a/src/ts/propValidators.ts +++ b/src/ts/propValidators.ts @@ -1,42 +1,57 @@ /* eslint-disable @typescript-eslint/no-empty-function */ -import type { PluginOptions } from "../types" -import { EventBus } from "./eventBus" -import { POSITION, VT_NAMESPACE } from "./constants" import { InferDefaults } from "../types/vue-helper" +import type { ToastOptions } from "../types/toast" +import type { ToastContainerOptions } from "../types/toastContainer" + +import { POSITION, VT_NAMESPACE, TYPE } from "./constants" +import { EventBus } from "./eventBus" + +const defaultEventBus = /* istanbul ignore next */ () => new EventBus() +const emptyFunction = /* istanbul ignore next */ () => {} + // This wraps a method to be returned as a factory function const asFactory = (f: T) => (() => f) as unknown as T -export const PLUGIN_DEFAULTS: Required>> = - { - accessibility: () => ({ - toastRole: "alert", - closeButtonLabel: "close", - }), - bodyClassName: () => [], - closeButton: () => "button", - closeButtonClassName: () => [], - closeOnClick: true, - container: () => document.body, - containerClassName: () => [], - draggable: true, - draggablePercent: 0.6, - eventBus: /* istanbul ignore next */ () => new EventBus(), - filterBeforeCreate: asFactory(toast => toast), - filterToasts: asFactory(toasts => toasts), - hideProgressBar: false, - icon: () => true, - maxToasts: 20, - newestOnTop: true, - onMounted: () => {}, - pauseOnFocusLoss: true, - pauseOnHover: true, - position: POSITION.TOP_RIGHT, - rtl: false, - shareAppContext: false, - showCloseButtonOnHover: false, - timeout: 5000, - toastClassName: () => [], - toastDefaults: () => ({}), - transition: `${VT_NAMESPACE}__bounce`, - } +export const TOAST_DEFAULTS: Required>> = { + id: 0, + accessibility: () => ({ + toastRole: "alert", + closeButtonLabel: "close", + }), + bodyClassName: () => [], + closeButton: () => "button", + closeButtonClassName: () => [], + closeOnClick: true, + draggable: true, + draggablePercent: 0.6, + eventBus: defaultEventBus, + hideProgressBar: false, + icon: () => true, + pauseOnFocusLoss: true, + pauseOnHover: true, + position: POSITION.TOP_RIGHT, + rtl: false, + showCloseButtonOnHover: false, + timeout: 5000, + toastClassName: () => [], + onClick: emptyFunction, + onClose: emptyFunction, + type: TYPE.DEFAULT, +} + +export const TOAST_CONTAINER_DEFAULTS: Required< + InferDefaults> +> = { + position: TOAST_DEFAULTS.position, + container: () => document.body, + containerClassName: () => [], + eventBus: defaultEventBus, + filterBeforeCreate: asFactory(toast => toast), + filterToasts: asFactory(toasts => toasts), + maxToasts: 20, + newestOnTop: true, + toastDefaults: () => ({}), + transition: `${VT_NAMESPACE}__bounce`, + defaultToastProps: /* istanbul ignore next */ () => ({}), +} diff --git a/src/ts/utils.ts b/src/ts/utils.ts index 947e636..da85d47 100644 --- a/src/ts/utils.ts +++ b/src/ts/utils.ts @@ -1,9 +1,12 @@ import { Component, defineComponent, toRaw, unref } from "vue" + +import type { BasePluginOptions } from "../types/plugin" import type { ToastComponent, ToastContent, RenderableToastContent, -} from "../types" +} from "../types/toast" +import type { ToastContainerOptions } from "../types/toastContainer" interface DictionaryLike { [index: string]: unknown @@ -123,6 +126,43 @@ const normalizeToastComponent = (obj: ToastContent): ToastContent => { const isBrowser = () => typeof window !== "undefined" +const asContainerProps = ( + options: BasePluginOptions +): ToastContainerOptions => { + const { + position, + container, + newestOnTop, + maxToasts, + transition, + toastDefaults, + eventBus, + filterBeforeCreate, + filterToasts, + containerClassName, + ...defaultToastProps + } = options + const containerProps = { + position, + container, + newestOnTop, + maxToasts, + transition, + toastDefaults, + eventBus, + filterBeforeCreate, + filterToasts, + containerClassName, + defaultToastProps, + } + const keys = Object.keys(containerProps) as (keyof ToastContainerOptions)[] + keys.forEach( + key => + typeof containerProps[key] === "undefined" && delete containerProps[key] + ) + return containerProps +} + export { getId, getX, @@ -139,4 +179,5 @@ export { isFunction, isBrowser, getProp, + asContainerProps, } diff --git a/src/types/common.ts b/src/types/common.ts new file mode 100644 index 0000000..e91d516 --- /dev/null +++ b/src/types/common.ts @@ -0,0 +1,60 @@ +import type { Component } from "vue" + +import type { EventBusInterface } from "../ts/eventBus" + +export declare type ToastID = string | number + +export declare type ClassNames = string | string[] + +export declare interface EventBusable { + /** + * EventBus instance used to pass events across the interface + * + * Created by default, but you can use your own if you want + */ + eventBus?: EventBusInterface +} + +export declare interface Draggable { + /** + * Position of the toast on the screen. + * + * Can be any of top-right, top-center, top-left, bottom-right, bottom-center, bottom-left. + */ + draggable?: boolean + /** + * By how much of the toast width in percent (0 to 1) it must be dragged before being dismissed. + */ + draggablePercent?: number +} + +export declare interface Hoverable { + /** + * Whether or not the toast is paused when it is hovered by the mouse. + */ + pauseOnHover?: boolean +} + +export declare interface Focusable { + /** + * Whether or not the toast is paused when the window loses focus. + */ + pauseOnFocusLoss?: boolean +} + +export declare type Icon = + | boolean + | string + | { + iconTag?: keyof HTMLElementTagNameMap + iconChildren?: string + iconClass?: string + } + | Component + | JSX.Element + +export declare type Button = + | false + | keyof HTMLElementTagNameMap + | Component + | JSX.Element diff --git a/src/types/index.ts b/src/types/index.ts deleted file mode 100644 index 63d082c..0000000 --- a/src/types/index.ts +++ /dev/null @@ -1,233 +0,0 @@ -import type { App, Component, ComponentPublicInstance } from "vue" -import type { EventBusInterface } from "../ts/eventBus" -import type { TYPE, POSITION } from "../ts/constants" - -export type ToastID = string | number - -export type ClassNames = string | string[] - -export interface CommonOptions { - /** - * Position of the toast on the screen. - * - * Can be any of top-right, top-center, top-left, bottom-right, bottom-center, bottom-left. - */ - position?: POSITION - /** - * Position of the toast on the screen. - * - * Can be any of top-right, top-center, top-left, bottom-right, bottom-center, bottom-left. - */ - draggable?: boolean - /** - * By how much of the toast width in percent (0 to 1) it must be dragged before being dismissed. - */ - draggablePercent?: number - /** - * Whether or not the toast is paused when the window loses focus. - */ - pauseOnFocusLoss?: boolean - /** - * Whether or not the toast is paused when it is hovered by the mouse. - */ - pauseOnHover?: boolean - /** - * Whether or not the toast is closed when clicked. - */ - closeOnClick?: boolean - /** - * How many milliseconds for the toast to be auto dismissed, or false to disable. - */ - timeout?: number | false - /** - * Custom classes applied to the toast. - */ - toastClassName?: ClassNames - /** - * Custom classes applied to the body of the toast. - */ - bodyClassName?: ClassNames - /** - * Whether or not the progress bar is hidden. - */ - hideProgressBar?: boolean - /** - * Only shows the close button when hovering the toast - */ - showCloseButtonOnHover?: boolean - /** - * Custom icon class to be used. - * - * When set to `true`, the icon is set automatically depending on the toast type and `false` disables the icon. - */ - icon?: - | boolean - | string - | { - iconTag?: keyof HTMLElementTagNameMap - iconChildren?: string - iconClass?: string - } - | Component - | JSX.Element - /** - * Custom close button component - * - * Alternative close button component to be displayed in toasts - */ - closeButton?: false | keyof HTMLElementTagNameMap | Component | JSX.Element - /** - * Custom classes applied to the close button of the toast. - */ - closeButtonClassName?: ClassNames - /** - * Accessibility options - */ - accessibility?: { - /** - * Toast accessibility role - * - * Accessibility option "role" for screen readers. Defaults to "alert". - */ - toastRole?: string - /** - * Close button label - * - * Accessibility option of the closeButton's "label" for screen readers. Defaults to "close". - */ - closeButtonLabel?: string - } - /** - * Right-to-Left support. - * - * If true, switches the toast contents from right to left. Defaults to false. - */ - rtl?: boolean - /** - * EventBus instance used to pass events across the interface - * - * Created by default, but you can use your own if you want - */ - eventBus?: EventBusInterface -} - -type ContainerCallback = () => HTMLElement | Promise - -export interface PluginOptions extends CommonOptions { - /** - * Container where the toasts are mounted. - */ - container?: HTMLElement | ContainerCallback - /** - * Whether or not the newest toasts are placed on the top of the stack. - */ - newestOnTop?: boolean - /** - * Maximum number of toasts on each stack at a time. Overflows wait until older toasts are dismissed to appear. - */ - maxToasts?: number - /** - * Name of the Vue Transition or object with classes to use. - * - * Only `enter-active`, `leave-active` and `move` are applied. - */ - transition?: string | Record<"enter" | "leave" | "move", string> - /** - * Toast's defaults object for configuring default toast options for each toast type. - * - * Possible object properties can be any of `success error default info warning` - */ - toastDefaults?: Partial> - /** - * Callback to filter toasts during creation - * - * Takes the new toast and a list of the current toasts and returns a modified toast or false. - */ - filterBeforeCreate?: ( - toast: ToastOptionsAndRequiredContent, - toasts: ToastOptionsAndRequiredContent[] - ) => ToastOptionsAndRequiredContent | false - /** - * Callback to filter toasts during render - * - * Filter toasts during render and queues filtered toasts. - */ - filterToasts?: ( - toasts: ToastOptionsAndRequiredContent[] - ) => ToastOptionsAndRequiredContent[] - /** - * Extra CSS class or classes added to each of the Toast containers. - * - * Keep in mind that there is one container for each possible toast position. - */ - containerClassName?: ClassNames - /** - * Callback executed when the toast container is mounted. - * - * Receives the Container vue instance as a parameter. - */ - onMounted?: ( - containerComponent: ComponentPublicInstance, - containerApp: App - ) => void - /** - * Shares the context of your app with your toasts - * - * This allows toasts to use your app's plugins, mixins, global components, etc. - * - * If you set it to `true`, the app wherein the plugin is installed will be used. - * You may also provide the app instance you wish to use. - */ - shareAppContext?: boolean | App -} - -export interface ToastOptions extends CommonOptions { - /** - * ID of the toast. - */ - id?: ToastID - /** - * Type of the toast. - * - * Can be any of `success error default info warning` - */ - type?: TYPE - /** - * Callback executed when the toast is clicked. - * - * A closeToast callback is passed as argument to onClick when it is called. - */ - // eslint-disable-next-line @typescript-eslint/ban-types - onClick?: (closeToast: Function) => void - /** - * Callback executed when the toast is closed. - */ - onClose?: () => void -} - -export type RenderableToastContent = string | Component - -export interface ToastComponent { - /** - * Component that will be rendered. - */ - component: ToastContent - /** - * `propName: propValue` pairs of props that will be passed to the component. - * - * __These are not reactive__ - */ - props?: { [propName: string]: unknown } - /** - * `eventName: eventHandler` pairs of events that the component can emit. - */ - // eslint-disable-next-line @typescript-eslint/ban-types - listeners?: { [listenerEvent: string]: Function } -} - -export type ToastContent = RenderableToastContent | JSX.Element | ToastComponent - -export type ToastOptionsAndContent = ToastOptions & { content?: ToastContent } -export type ToastOptionsAndRequiredContent = ToastOptions & { - content: ToastContent -} diff --git a/src/types/plugin.ts b/src/types/plugin.ts new file mode 100644 index 0000000..b3194b3 --- /dev/null +++ b/src/types/plugin.ts @@ -0,0 +1,29 @@ +import type { App, ComponentPublicInstance } from "vue" + +import type { BaseToastOptions } from "./toast" +import type { BaseToastContainerOptions } from "./toastContainer" + +export declare interface BasePluginOptions + extends BaseToastContainerOptions, + BaseToastOptions {} + +export declare interface PluginOptions extends BasePluginOptions { + /** + * Callback executed when the toast container is mounted. + * + * Receives the Container vue instance as a parameter. + */ + onMounted?: ( + containerComponent: ComponentPublicInstance, + containerApp: App + ) => void + /** + * Shares the context of your app with your toasts + * + * This allows toasts to use your app's plugins, mixins, global components, etc. + * + * If you set it to `true`, the app wherein the plugin is installed will be used. + * You may also provide the app instance you wish to use. + */ + shareAppContext?: boolean | App +} diff --git a/src/types/toast.ts b/src/types/toast.ts new file mode 100644 index 0000000..f2c94f7 --- /dev/null +++ b/src/types/toast.ts @@ -0,0 +1,143 @@ +import type { Component } from "vue" + +import type { TYPE, POSITION } from "../ts/constants" +import type { + Button, + ClassNames, + Draggable, + EventBusable, + Focusable, + Hoverable, + Icon, + ToastID, +} from "./common" + +export declare interface BaseToastOptions + extends EventBusable, + Draggable, + Hoverable, + Focusable { + /** + * Position of the toast on the screen. + * + * Can be any of top-right, top-center, top-left, bottom-right, bottom-center, bottom-left. + */ + position?: POSITION + + /** + * Whether or not the toast is closed when clicked. + */ + closeOnClick?: boolean + /** + * How many milliseconds for the toast to be auto dismissed, or false to disable. + */ + timeout?: number | false + /** + * Custom classes applied to the toast. + */ + toastClassName?: ClassNames + /** + * Custom classes applied to the body of the toast. + */ + bodyClassName?: ClassNames + /** + * Whether or not the progress bar is hidden. + */ + hideProgressBar?: boolean + /** + * Only shows the close button when hovering the toast + */ + showCloseButtonOnHover?: boolean + /** + * Custom icon class to be used. + * + * When set to `true`, the icon is set automatically depending on the toast type and `false` disables the icon. + */ + icon?: Icon + /** + * Custom close button component + * + * Alternative close button component to be displayed in toasts + */ + closeButton?: Button + /** + * Custom classes applied to the close button of the toast. + */ + closeButtonClassName?: ClassNames + /** + * Accessibility options + */ + accessibility?: { + /** + * Toast accessibility role + * + * Accessibility option "role" for screen readers. Defaults to "alert". + */ + toastRole?: string + /** + * Close button label + * + * Accessibility option of the closeButton's "label" for screen readers. Defaults to "close". + */ + closeButtonLabel?: string + } + /** + * Right-to-Left support. + * + * If true, switches the toast contents from right to left. Defaults to false. + */ + rtl?: boolean +} + +export declare interface ToastOptions extends BaseToastOptions { + /** + * ID of the toast. + */ + id?: ToastID + /** + * Type of the toast. + * + * Can be any of `success error default info warning` + */ + type?: TYPE + /** + * Callback executed when the toast is clicked. + * + * A closeToast callback is passed as argument to onClick when it is called. + */ + // eslint-disable-next-line @typescript-eslint/ban-types + onClick?: (closeToast: Function) => void + /** + * Callback executed when the toast is closed. + */ + onClose?: () => void +} + +export declare type RenderableToastContent = string | Component + +export declare interface ToastComponent { + /** + * Component that will be rendered. + */ + component: ToastContent + /** + * `propName: propValue` pairs of props that will be passed to the component. + * + * __These are not reactive__ + */ + props?: { [propName: string]: unknown } + /** + * `eventName: eventHandler` pairs of events that the component can emit. + */ + // eslint-disable-next-line @typescript-eslint/ban-types + listeners?: { [listenerEvent: string]: Function } +} + +export declare type ToastContent = + | RenderableToastContent + | JSX.Element + | ToastComponent + +export declare type ToastOptionsAndContent = ToastOptions & { + content: ToastContent +} diff --git a/src/types/toastContainer.ts b/src/types/toastContainer.ts new file mode 100644 index 0000000..c1016e2 --- /dev/null +++ b/src/types/toastContainer.ts @@ -0,0 +1,63 @@ +import type { TYPE } from "../ts/constants" +import type { ClassNames, EventBusable } from "./common" +import type { + BaseToastOptions, + ToastOptions, + ToastOptionsAndContent, +} from "./toast" + +type ContainerCallback = () => HTMLElement | Promise + +export declare interface BaseToastContainerOptions extends EventBusable { + position?: BaseToastOptions["position"] + /** + * Container where the toasts are mounted. + */ + container?: HTMLElement | ContainerCallback + /** + * Whether or not the newest toasts are placed on the top of the stack. + */ + newestOnTop?: boolean + /** + * Maximum number of toasts on each stack at a time. Overflows wait until older toasts are dismissed to appear. + */ + maxToasts?: number + /** + * Name of the Vue Transition or object with classes to use. + * + * Only `enter-active`, `leave-active` and `move` are applied. + */ + transition?: string | Record<"enter" | "leave" | "move", string> + /** + * Toast's defaults object for configuring default toast options for each toast type. + * + * Possible object properties can be any of `success error default info warning` + */ + toastDefaults?: Partial> + /** + * Callback to filter toasts during creation + * + * Takes the new toast and a list of the current toasts and returns a modified toast or false. + */ + filterBeforeCreate?: ( + toast: ToastOptionsAndContent, + toasts: ToastOptionsAndContent[] + ) => ToastOptionsAndContent | false + /** + * Callback to filter toasts during render + * + * Filter toasts during render and queues filtered toasts. + */ + filterToasts?: (toasts: ToastOptionsAndContent[]) => ToastOptionsAndContent[] + /** + * Extra CSS class or classes added to each of the Toast containers. + * + * Keep in mind that there is one container for each possible toast position. + */ + containerClassName?: ClassNames +} + +export declare interface ToastContainerOptions + extends BaseToastContainerOptions { + defaultToastProps?: BaseToastOptions +} diff --git a/tests/unit/components/VtCloseButton.spec.ts b/tests/unit/components/VtCloseButton.spec.ts index 9e04139..b024a0a 100644 --- a/tests/unit/components/VtCloseButton.spec.ts +++ b/tests/unit/components/VtCloseButton.spec.ts @@ -1,5 +1,7 @@ -import { mount } from "@vue/test-utils" import { markRaw } from "vue" + +import { mount } from "@vue/test-utils" + import VtCloseButton from "../../../src/components/VtCloseButton.vue" import { VT_NAMESPACE } from "../../../src/ts/constants" import Simple from "../../utils/components/Simple.vue" diff --git a/tests/unit/components/VtIcon.spec.ts b/tests/unit/components/VtIcon.spec.ts index 73fcced..5cf78cd 100644 --- a/tests/unit/components/VtIcon.spec.ts +++ b/tests/unit/components/VtIcon.spec.ts @@ -1,12 +1,14 @@ +import { markRaw } from "vue" + import { mount } from "@vue/test-utils" -import { TYPE, VT_NAMESPACE } from "../../../src/ts/constants" -import VtIcon from "../../../src/components/VtIcon.vue" -import VtSuccessIcon from "../../../src/components/icons/VtSuccessIcon.vue" + +import VtErrorIcon from "../../../src/components/icons/VtErrorIcon.vue" import VtInfoIcon from "../../../src/components/icons/VtInfoIcon.vue" +import VtSuccessIcon from "../../../src/components/icons/VtSuccessIcon.vue" import VtWarningIcon from "../../../src/components/icons/VtWarningIcon.vue" -import VtErrorIcon from "../../../src/components/icons/VtErrorIcon.vue" +import VtIcon from "../../../src/components/VtIcon.vue" +import { TYPE, VT_NAMESPACE } from "../../../src/ts/constants" import Simple from "../../utils/components/Simple.vue" -import { markRaw } from "vue" describe("VtIcon", () => { describe("snapshots", () => { diff --git a/tests/unit/components/VtProgressBar.spec.ts b/tests/unit/components/VtProgressBar.spec.ts index caf902e..3ed16ab 100644 --- a/tests/unit/components/VtProgressBar.spec.ts +++ b/tests/unit/components/VtProgressBar.spec.ts @@ -1,5 +1,7 @@ -import { mount } from "@vue/test-utils" import { nextTick } from "vue" + +import { mount } from "@vue/test-utils" + import VtProgressBar from "../../../src/components/VtProgressBar.vue" import { VT_NAMESPACE } from "../../../src/ts/constants" diff --git a/tests/unit/components/VtToast.spec.ts b/tests/unit/components/VtToast.spec.ts index 9ce80e8..0bae3b5 100644 --- a/tests/unit/components/VtToast.spec.ts +++ b/tests/unit/components/VtToast.spec.ts @@ -1,16 +1,19 @@ import { ComponentPublicInstance, nextTick, ref } from "vue" + import { mount, VueWrapper } from "@vue/test-utils" import merge from "lodash.merge" -import VtToast from "../../../src/components/VtToast.vue" + +import { EventBus } from "../../../src" +import VtCloseButton from "../../../src/components/VtCloseButton.vue" import VtIcon from "../../../src/components/VtIcon.vue" import VtProgressBar from "../../../src/components/VtProgressBar.vue" -import VtCloseButton from "../../../src/components/VtCloseButton.vue" -import { ToastOptionsAndContent } from "../../../src/types" +import VtToast from "../../../src/components/VtToast.vue" +import * as useDraggableModule from "../../../src/ts/composables/useDraggable" import { VT_NAMESPACE, TYPE, POSITION, EVENTS } from "../../../src/ts/constants" -import Simple from "../../utils/components/Simple.vue" -import { EventBus } from "../../../src" import { normalizeToastComponent } from "../../../src/ts/utils" -import * as useDraggableModule from "../../../src/ts/composables/useDraggable" +import Simple from "../../utils/components/Simple.vue" + +import type { ToastOptionsAndContent } from "../../../src/types/toast" const setData = ( wrapper: VueWrapper, @@ -19,7 +22,11 @@ const setData = ( merge(wrapper.vm, override) } -const mountToast = ({ id, content, ...props }: ToastOptionsAndContent = {}) => +const mountToast = ({ + id, + content, + ...props +}: Partial = {}) => mount(VtToast, { props: { id: id || 1, diff --git a/tests/unit/components/VtToastContainer.spec.ts b/tests/unit/components/VtToastContainer.spec.ts index 7d67fba..0739e44 100644 --- a/tests/unit/components/VtToastContainer.spec.ts +++ b/tests/unit/components/VtToastContainer.spec.ts @@ -1,17 +1,25 @@ -import { PluginOptions } from "../../../src/types" -import { EVENTS, POSITION, TYPE } from "../../../src/ts/constants" -import VtToastContainer from "../../../src/components/VtToastContainer.vue" -import VtToast from "../../../src/components/VtToast.vue" -import VtProgressBar from "../../../src/components/VtProgressBar.vue" import { ComponentPublicInstance, h, nextTick } from "vue" + import { mount, VueWrapper } from "@vue/test-utils" -import { createToastInterface, EventBus } from "../../../src" + +import { createToastInstance, EventBus } from "../../../src" +import VtProgressBar from "../../../src/components/VtProgressBar.vue" +import VtToast from "../../../src/components/VtToast.vue" +import VtToastContainer from "../../../src/components/VtToastContainer.vue" +import { EVENTS, POSITION, TYPE } from "../../../src/ts/constants" +import { asContainerProps } from "../../../src/ts/utils" + +import type { PluginOptions } from "../../../src/types/plugin" const mountToastContainer = async (props: PluginOptions = {}) => { const eventBus = new EventBus() - const toast = createToastInterface(eventBus) + const toast = createToastInstance(eventBus) + const options: PluginOptions = { + eventBus, + ...props, + } const wrapper = mount(VtToastContainer, { - props: { container: undefined, eventBus, ...props }, + props: { container: undefined, ...asContainerProps(options) }, }) await nextTick() return { diff --git a/tests/unit/components/VtTransition.spec.ts b/tests/unit/components/VtTransition.spec.ts index 6e07eda..5183b7d 100644 --- a/tests/unit/components/VtTransition.spec.ts +++ b/tests/unit/components/VtTransition.spec.ts @@ -1,5 +1,7 @@ import { TransitionGroup } from "vue" + import { mount } from "@vue/test-utils" + import VtTransition from "../../../src/components/VtTransition.vue" const asEmitter = (arg: unknown) => diff --git a/tests/unit/components/__snapshots__/VtIcon.spec.ts.snap b/tests/unit/components/__snapshots__/VtIcon.spec.ts.snap index 6f91cc7..0c939d9 100644 --- a/tests/unit/components/__snapshots__/VtIcon.spec.ts.snap +++ b/tests/unit/components/__snapshots__/VtIcon.spec.ts.snap @@ -43,9 +43,7 @@ exports[`VtIcon renders custom components renders custom icon tag 1`] = ` exports[`VtIcon renders custom components renders regular icon if true 1`] = `
app
" }) - // App starts off empty - expect(baseApp._context.components).toEqual({}) - expect(baseApp._context.directives).toEqual({}) - expect(baseApp._context.mixins.length).toEqual(0) - expect(baseApp._context.provides).toEqual({}) - expect(baseApp.config.globalProperties).toEqual({}) - - // Add a plugin that sets globalProps - baseApp.use(App => { - App.config.globalProperties.newProp = "text" + beforeEach(() => { + eventBus = new EventBus() + eventsEmmited = Object.values(EVENTS).reduce((agg, eventName) => { + const handler = jest.fn() + eventBus.on(eventName, handler) + return { ...agg, [eventName]: handler } + }, {} as { [eventName in EVENTS]: jest.Mock }) }) - // A custom componrnt - baseApp.component("CustomApp", { template: "
" }) - // A custom directive - baseApp.directive("customDir", {}) - // Provide some data - baseApp.provide("provideKey", "value") - // And a custom mixin - baseApp.mixin({ - setup() { - return { stuff: 123 } - }, + + it("creates valid interface by default", async () => { + const mockApp = { mount: jest.fn() } as unknown as App + jest.spyOn(vue, "createApp").mockImplementation(() => mockApp) + const toast = buildInterface() + await nextTick() + + expect(isFunction(toast)).toBe(true) + expect(isFunction(toast.info)).toBe(true) + expect(isFunction(toast.success)).toBe(true) + expect(isFunction(toast.warning)).toBe(true) + expect(isFunction(toast.error)).toBe(true) + expect(isFunction(toast.dismiss)).toBe(true) + expect(isFunction(toast.clear)).toBe(true) + expect(isFunction(toast.update)).toBe(true) + expect(isFunction(toast.updateDefaults)).toBe(true) }) - // Confirm that app has all values - expect(baseApp._context.components).not.toEqual({}) - expect(baseApp._context.directives).not.toEqual({}) - expect(baseApp._context.mixins.length).not.toEqual(0) - expect(baseApp._context.provides).not.toEqual({}) - expect(baseApp.config.globalProperties).not.toEqual({}) + it("uses provided eventBus", async () => { + const mockApp = { mount: jest.fn() } as unknown as App + const createAppSpy = jest + .spyOn(vue, "createApp") + .mockImplementation(() => mockApp) + const toast = buildInterface({ eventBus }) - let toastApp: App | undefined = undefined - const pluginOptions: index.PluginOptions = { - onMounted: (_, app) => { - toastApp = app - }, - shareAppContext: true, - } - baseApp.use(index.default, pluginOptions) - await nextTick() + expect(eventsEmmited.add).not.toHaveBeenCalled() - toastApp = toastApp as unknown as App + const content = "hello" + toast.success(content) - // toast app should share configs with app - expect(toastApp).toBeDefined() - expect(toastApp._context.components).toBe(baseApp._context.components) - expect(toastApp._context.directives).toBe(baseApp._context.directives) - expect(toastApp._context.mixins).toBe(baseApp._context.mixins) - expect(toastApp._context.provides).toBe(baseApp._context.provides) - expect(toastApp.config.globalProperties).toBe( - baseApp.config.globalProperties - ) - }) + expect(eventsEmmited.add).toHaveBeenCalledWith({ + id: expect.any(Number), + type: TYPE.SUCCESS, + content, + }) - it("Does not share app context if shareAppContext = true", async () => { - // Create a base app - const baseApp = vue.createApp({ template: "
app
" }) - // App starts off empty - expect(baseApp._context.components).toEqual({}) - expect(baseApp._context.directives).toEqual({}) - expect(baseApp._context.mixins.length).toEqual(0) - expect(baseApp._context.provides).toEqual({}) - expect(baseApp.config.globalProperties).toEqual({}) + await nextTick() - // Add a plugin that sets globalProps - baseApp.use(App => { - App.config.globalProperties.newProp = "text" - }) - // A custom componrnt - baseApp.component("CustomApp", { template: "
" }) - // A custom directive - baseApp.directive("customDir", {}) - // Provide some data - baseApp.provide("provideKey", "value") - // And a custom mixin - baseApp.mixin({ - setup() { - return { stuff: 123 } - }, + expect(createAppSpy).toHaveBeenCalledWith(VtToastContainer, { + eventBus, + defaultToastProps: {}, + }) }) - // Confirm that app has all values - expect(baseApp._context.components).not.toEqual({}) - expect(baseApp._context.directives).not.toEqual({}) - expect(baseApp._context.mixins.length).not.toEqual(0) - expect(baseApp._context.provides).not.toEqual({}) - expect(baseApp.config.globalProperties).not.toEqual({}) + it("mounts container by default", async () => { + const mockApp = { mount: jest.fn() } as unknown as App + const createAppSpy = jest + .spyOn(vue, "createApp") + .mockImplementation(() => mockApp) + + buildInterface() + + expect(mockApp.mount).not.toHaveBeenCalled() + expect(createAppSpy).not.toHaveBeenCalled() + await nextTick() + + expect(createAppSpy).toHaveBeenCalledWith( + VtToastContainer, + expect.objectContaining({ + eventBus: expect.any(EventBus), + }) + ) + expect(mockApp.mount).toHaveBeenCalled() + }) - let toastApp: App | undefined = undefined - const pluginOptions: index.PluginOptions = { - onMounted: (_, app) => { - toastApp = app - }, - } - baseApp.use(index.default, pluginOptions) - await nextTick() + it("passes props to mounted container", async () => { + const mockApp = { mount: jest.fn() } as unknown as App + const createAppSpy = jest + .spyOn(vue, "createApp") + .mockImplementation(() => mockApp) + + const options: PluginOptions = { + timeout: 1000, + bodyClassName: "myclass", + } + buildInterface(options) + await nextTick() + + expect(createAppSpy).toHaveBeenCalledWith(VtToastContainer, { + eventBus: expect.any(EventBus), + defaultToastProps: { ...options }, + }) + expect(mockApp.mount).toHaveBeenCalled() + }) - toastApp = toastApp as unknown as App + it("calls onMounted", async () => { + const component = {} + const mockApp = { mount: jest.fn(() => component) } as unknown as App + jest.spyOn(vue, "createApp").mockImplementation(() => mockApp) - // toast app should share configs with app - expect(toastApp).toBeDefined() - expect(toastApp._context.components).toEqual({}) - expect(toastApp._context.directives).toEqual({}) - expect(toastApp._context.mixins.length).toEqual(0) - expect(toastApp._context.provides).toEqual({}) - expect(toastApp.config.globalProperties).toEqual({}) - }) + const onMounted = jest.fn() + buildInterface({ onMounted }) - it("warns if shareAppContext = true in buildInterface", async () => { - const consoleSpy = jest.spyOn(console, "warn").mockImplementation() - expect(consoleSpy).toHaveBeenCalledTimes(0) - buildInterface({ shareAppContext: true }, true) - await nextTick() - expect(consoleSpy).toHaveBeenCalledTimes(1) - }) + expect(onMounted).not.toHaveBeenCalled() + await nextTick() - it("calls regular toast function with defaults", () => { - expect(eventsEmmited.add).not.toHaveBeenCalled() - const content = "content" - const id = toast(content) - expect(eventsEmmited.add).toHaveBeenCalledTimes(1) - expect(eventsEmmited.add).toBeCalledWith({ - id: expect.any(Number), - type: TYPE.DEFAULT, - content, + expect(onMounted).toHaveBeenCalledWith(component, mockApp) }) - expect(typeof id).toBe("number") - }) - it("calls regular toast function with extra values", () => { - expect(eventsEmmited.add).not.toHaveBeenCalled() - const content = "content" - const options: ToastOptions = { timeout: 1000 } - const id = toast(content, options) - expect(eventsEmmited.add).toHaveBeenCalledTimes(1) - expect(eventsEmmited.add).toBeCalledWith({ - id: expect.any(Number), - type: TYPE.DEFAULT, - content, - ...options, - }) - expect(typeof id).toBe("number") - }) - it("calls clear", () => { - expect(eventsEmmited.clear).not.toHaveBeenCalled() - toast.clear() - expect(eventsEmmited.clear).toHaveBeenCalledTimes(1) - }) - it("calls updateDefaults", () => { - expect(eventsEmmited.update_defaults).not.toHaveBeenCalled() - toast.updateDefaults({ timeout: 1000 }) - expect(eventsEmmited.update_defaults).toHaveBeenCalledTimes(1) - expect(eventsEmmited.update_defaults).toBeCalledWith({ timeout: 1000 }) - }) - it("calls dismiss", () => { - expect(eventsEmmited.dismiss).not.toHaveBeenCalled() - toast.dismiss(10) - expect(eventsEmmited.dismiss).toHaveBeenCalledTimes(1) - expect(eventsEmmited.dismiss).toBeCalledWith(10) - }) - it("calls update with content", () => { - expect(eventsEmmited.update).not.toHaveBeenCalled() - const id = 10 - const content = "content" - toast.update(id, { content }) - expect(eventsEmmited.update).toHaveBeenCalledTimes(1) - expect(eventsEmmited.update).toBeCalledWith({ - id, - options: { content }, - create: false, - }) - }) - it("calls update with options", () => { - expect(eventsEmmited.update).not.toHaveBeenCalled() - const id = 10 - const options: ToastOptions = { timeout: 1000 } - toast.update(id, { options }) - expect(eventsEmmited.update).toHaveBeenCalledTimes(1) - expect(eventsEmmited.update).toBeCalledWith({ - id, - options: { ...options, content: undefined }, - create: false, - }) - }) - it("calls update with create", () => { - expect(eventsEmmited.update).not.toHaveBeenCalled() - const id = 10 - const content = "abc" - toast.update(id, { content }, true) - expect(eventsEmmited.update).toHaveBeenCalledTimes(1) - expect(eventsEmmited.update).toBeCalledWith({ - id, - options: { content }, - create: true, - }) - }) - it("calls success", () => { - expect(eventsEmmited.add).not.toHaveBeenCalled() - const content = "abc" - toast.success(content) - expect(eventsEmmited.add).toHaveBeenCalledTimes(1) - expect(eventsEmmited.add).toBeCalledWith({ - id: expect.any(Number), - type: TYPE.SUCCESS, - content, - }) - }) - it("calls info", () => { - expect(eventsEmmited.add).not.toHaveBeenCalled() - const content = "abc" - toast.info(content) - expect(eventsEmmited.add).toHaveBeenCalledTimes(1) - expect(eventsEmmited.add).toBeCalledWith({ - id: expect.any(Number), - type: TYPE.INFO, - content, - }) - }) - it("calls error", () => { - expect(eventsEmmited.add).not.toHaveBeenCalled() - const content = "abc" - toast.error(content) - expect(eventsEmmited.add).toHaveBeenCalledTimes(1) - expect(eventsEmmited.add).toBeCalledWith({ - id: expect.any(Number), - type: TYPE.ERROR, - content, - }) - }) - it("calls warning", () => { - expect(eventsEmmited.add).not.toHaveBeenCalled() - const content = "abc" - toast.warning(content) - expect(eventsEmmited.add).toHaveBeenCalledTimes(1) - expect(eventsEmmited.add).toBeCalledWith({ - id: expect.any(Number), - type: TYPE.WARNING, - content, + + it("shares app context", async () => { + const mockApp = { + mount: jest.fn(), + _context: {}, + config: {}, + } as unknown as App + jest.spyOn(vue, "createApp").mockImplementation(() => mockApp) + + const userApp = { + _context: { + components: "components", + directives: "directives", + mixins: "mixins", + provides: "provides", + }, + config: { + globalProperties: "globalProperties", + }, + } as unknown as App + buildInterface({ shareAppContext: userApp }) + + await nextTick() + + expect(mockApp._context.components).toBe(userApp._context.components) + expect(mockApp._context.directives).toBe(userApp._context.directives) + expect(mockApp._context.mixins).toBe(userApp._context.mixins) + expect(mockApp._context.provides).toBe(userApp._context.provides) + expect(mockApp.config.globalProperties).toBe( + userApp.config.globalProperties + ) }) }) }) diff --git a/tests/unit/ts/plugin.spec.ts b/tests/unit/ts/plugin.spec.ts new file mode 100644 index 0000000..15fc3db --- /dev/null +++ b/tests/unit/ts/plugin.spec.ts @@ -0,0 +1,126 @@ +import { App } from "vue" + +import { isFunction } from "@vue/shared" + +import { PluginOptions, ToastInterface, EventBus } from "../../../src" +import * as useToast from "../../../src/ts/composables/useToast" +import { globalEventBus } from "../../../src/ts/eventBus" +import * as plugin from "../../../src/ts/plugin" + +// eslint-disable-next-line @typescript-eslint/ban-types +type AsFunction = T extends Function ? T : never + +const pluginFunction = plugin.VueToastificationPlugin as AsFunction< + typeof plugin.VueToastificationPlugin +> + +describe("plugin", () => { + beforeEach(() => { + jest.resetAllMocks() + jest.restoreAllMocks() + }) + + describe("VueToastificationPlugin", () => { + it("plugin is a function", () => { + expect(isFunction(plugin.VueToastificationPlugin)).toBe(true) + }) + it("provides default if no options", () => { + const toast = {} as ToastInterface + const createToastInstanceSpy = jest + .spyOn(useToast, "createToastInstance") + .mockImplementation(() => toast) + + const mockApp = { provide: jest.fn() } as unknown as App + + expect(createToastInstanceSpy).not.toHaveBeenCalled() + + pluginFunction(mockApp) + + expect(createToastInstanceSpy).toHaveBeenCalledWith({ + eventBus: globalEventBus, + }) + expect(mockApp.provide).toHaveBeenCalledWith( + useToast.toastInjectionKey, + toast + ) + }) + + it("provides with options", () => { + const toast = {} as ToastInterface + const createToastInstanceSpy = jest + .spyOn(useToast, "createToastInstance") + .mockImplementation(() => toast) + + const mockApp = { provide: jest.fn() } as unknown as App + + expect(createToastInstanceSpy).not.toHaveBeenCalled() + + const options: PluginOptions = { timeout: 1000 } + pluginFunction(mockApp, options) + + expect(createToastInstanceSpy).toHaveBeenCalledWith({ + eventBus: globalEventBus, + timeout: 1000, + }) + expect(mockApp.provide).toHaveBeenCalledWith( + useToast.toastInjectionKey, + toast + ) + }) + + it("provides custom eventBus if provided", () => { + const toast = {} as ToastInterface + const createToastInstanceSpy = jest + .spyOn(useToast, "createToastInstance") + .mockImplementation(() => toast) + + const mockApp = { provide: jest.fn() } as unknown as App + + expect(createToastInstanceSpy).not.toHaveBeenCalled() + + const eventBus = new EventBus() + const options: PluginOptions = { eventBus } + pluginFunction(mockApp, options) + + expect(createToastInstanceSpy).toHaveBeenCalledWith({ + eventBus, + }) + expect(mockApp.provide).toHaveBeenCalledWith( + useToast.toastInjectionKey, + toast + ) + }) + + it("does not share app context by default", () => { + const createToastInstanceSpy = jest + .spyOn(useToast, "createToastInstance") + .mockImplementation() + + const mockApp = { provide: jest.fn() } as unknown as App + + expect(createToastInstanceSpy).not.toHaveBeenCalled() + + pluginFunction(mockApp) + + expect(createToastInstanceSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ shareAppContext: mockApp }) + ) + }) + + it("shares app context if required", () => { + const createToastInstanceSpy = jest + .spyOn(useToast, "createToastInstance") + .mockImplementation() + + const mockApp = { provide: jest.fn() } as unknown as App + + expect(createToastInstanceSpy).not.toHaveBeenCalled() + + pluginFunction(mockApp, { shareAppContext: true }) + + expect(createToastInstanceSpy).toHaveBeenCalledWith( + expect.objectContaining({ shareAppContext: mockApp }) + ) + }) + }) +}) diff --git a/tests/unit/ts/utils.spec.ts b/tests/unit/ts/utils.spec.ts index 09ee121..53a7800 100644 --- a/tests/unit/ts/utils.spec.ts +++ b/tests/unit/ts/utils.spec.ts @@ -1,5 +1,6 @@ /* eslint-disable vue/one-component-per-file */ import { defineComponent, h, isProxy, isRef, reactive, ref } from "vue" + import { getId, getX, diff --git a/tests/utils/components/Simple.vue b/tests/utils/components/Simple.vue index db2183f..7358582 100644 --- a/tests/utils/components/Simple.vue +++ b/tests/utils/components/Simple.vue @@ -3,6 +3,7 @@