[Simple Web Apps] 2. Customizable Toast Notifications

In my previous article on the series of building simple web apps, I wrote about building simple toast notifications. Now, we will improve that to add some interesting customizations/configuration capabilities.

In this article, we are going to add some customizations to the existing Toast class that we have already created. For the sake of simplicity, I am continuing with the static way of accessing methods. You can however modify this to suit using instances as well.

We shall target 2 different levels of customization:

  1. Global level (It affects all the toast notifications)

  2. Toast level (It only affects a specific toast notification)

For the Global level customization, we can have fields like:

  1. Should Stack notifications?

  2. Placement

    1. Top

    2. Bottom

For the Toast level customization, we can have fields like:

  1. Should automatically disappear?

    1. If yes, after how long?
  2. Should invoke a callback on click?

Of course, many more customizations can be added but for appreciating the simplicity, I would just stop with these in this implementation.

High-level design

Global customization setting

Toast.config({
    stackable: false/true, (defaults to false)
    placement: 'top/bottom' (defaults to top)
});

This would set the global customization for the Toast.

💡 NOTE: If you have many parts of your view triggering notifications, everything would adhere to this global customization.

💡 Tip: If you want to have different global customizations for different flows, then you would have to go with the instance route (or) you would have to reset the Toast.config({ … })every time you want to make global customization.

Toast customization setting

// After global customization

Toast.info('info message', {
    disappearAfter: 1000, // defaults to -1 it won't disappear. values are in ms.
    onClick: callback, // defaults to null
});

The same structure goes for error and success toast notifications as well. After modifying the basic structures in the existing Toast class, this is what we have:

export interface IToastGlobalConfig {
    stackable?: boolean;
    placement?: 'top' | 'bottom'
}

export interface IToastConfig {
    disappearAfter?: number;
        onClick?: (event: MouseEvent) => void;
}

class Toast {

    ...

    static config(props: IToastGlobalConfig): void {
                ...
    }

    static info(message: string, props: IToastConfig): void {
        ...
    }

    static error(message: string, props: IToastConfig): void {
        ...
    }

    static success(message: string, props: IToastConfig): void {
        ...
    }
}

The modified Toast class would look like this:

class Toast {
    static GLOBAL_CONFIG: IToastGlobalConfig = {
        placement: 'top',
        stackable: false
    };

    static TOAST_CONFIG: IToastConfig = {
        disappearAfter: -1,
        onClick: undefined
    }

    private constructor() {
        throw Error('Instatiation is not permitted')
    }

    static config(props: IToastGlobalConfig): void {
        CONTAINER = null as any;
        Toast.GLOBAL_CONFIG = {
            ...Toast.GLOBAL_CONFIG,
            ...(props || {})
        }
    }

    static info(message: string, props: IToastConfig = Toast.TOAST_CONFIG): void {
        render(message, {
            background: '#f1efff',
            color: '#222e5b'
        }, { ...Toast.TOAST_CONFIG, ...props })
    }

    static error(message: string, props: IToastConfig = Toast.TOAST_CONFIG): void {
        render(message, {
            background: '#ffe4e4',
            color: '#ce7d7d'
        }, { ...Toast.TOAST_CONFIG, ...props })
    }

    static success(message: string, props: IToastConfig = Toast.TOAST_CONFIG): void {
        render(message, {
            background: '#dcf7e7',
            color: '#4d9a70'
        }, { ...Toast.TOAST_CONFIG, ...props })
    }
}

Implementation using Typescript

let BODY: HTMLBodyElement;
let CONTAINER: HTMLDivElement;
const TOAST_CONTAINER_ID = 'toastContainerElement';
const DEFAULTS: any = {
    display: 'flex',
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    padding: '10px',
};
const addStyles = (element: HTMLElement, styles: any): void => {
    const keys = Object.keys(styles);
    for (let i = 0; i < keys.length; ++i) {
        const key: any = keys[i] as string;
        element.style[key] = styles[key];
    }
}

const getContainerElement = (): HTMLDivElement => {
    const container = document.createElement('div');

    let placement: any = {
        top: 0
    };
    if (Toast.GLOBAL_CONFIG.placement === 'bottom') {
        placement = {
            bottom: 0
        }
    }
    addStyles(container, {
        position: 'fixed',
        ...placement,
        left: 0,
        right: 0,
        padding: 0,
        margin: 0,
        display: 'flex',
        flex: 1,
        flexDirection: 'column'
    })
    container.id = TOAST_CONTAINER_ID;
    return container;
}

const appendToBody = (element: HTMLElement, replace: boolean = false): void => {
    if (!BODY) {
        BODY = document.body as HTMLBodyElement;
    }
    if (!CONTAINER) {
        CONTAINER = getContainerElement()
    }
    if (replace) {
        (document.getElementById(TOAST_CONTAINER_ID) as HTMLDivElement)?.replaceChildren();
    }
    CONTAINER.appendChild(element);
    BODY.replaceChildren(CONTAINER);
}

const handleEvent = (element, onClick?: (event: MouseEvent) => void) => {
    if (typeof onClick === 'function') {
        element.addEventListener('click', onClick)
    }
}

const handleDisappearing = (element, disappearAfter) => {
    if (disappearAfter !== -1) {
        setTimeout(() => {
            (document.getElementById(TOAST_CONTAINER_ID) as HTMLDivElement)?.removeChild(element);
        }, disappearAfter)
    }
}

const render = (message: string, props: { background: string, color: string }, config: IToastConfig) => {
    const toast = document.createElement('div');
    toast.innerHTML = message;
    addStyles(toast, { ...DEFAULTS, ...props });
    handleEvent(toast, config.onClick)
    handleDisappearing(toast, config.disappearAfter)
    appendToBody(toast, !Toast.GLOBAL_CONFIG.stackable)
}

export interface IToastGlobalConfig {
    stackable?: boolean;
    placement?: 'top' | 'bottom'
}

export interface IToastConfig {
    disappearAfter?: number;
    onClick?: (event: MouseEvent) => void;
}

class Toast {
    static GLOBAL_CONFIG: IToastGlobalConfig = {
        placement: 'top',
        stackable: false
    };

    static TOAST_CONFIG: IToastConfig = {
        disappearAfter: -1,
        onClick: undefined
    }

    private constructor() {
        throw Error('Instatiation is not permitted')
    }

    static config(props: IToastGlobalConfig): void {
        CONTAINER = null as any;
        Toast.GLOBAL_CONFIG = {
            ...Toast.GLOBAL_CONFIG,
            ...(props || {})
        }
    }

    static info(message: string, props: IToastConfig = Toast.TOAST_CONFIG): void {
        render(message, {
            background: '#f1efff',
            color: '#222e5b'
        }, { ...Toast.TOAST_CONFIG, ...props })
    }

    static error(message: string, props: IToastConfig = Toast.TOAST_CONFIG): void {
        render(message, {
            background: '#ffe4e4',
            color: '#ce7d7d'
        }, { ...Toast.TOAST_CONFIG, ...props })
    }

    static success(message: string, props: IToastConfig = Toast.TOAST_CONFIG): void {
        render(message, {
            background: '#dcf7e7',
            color: '#4d9a70'
        }, { ...Toast.TOAST_CONFIG, ...props })
    }
}

export default Toast;

Implementation using JavaScript

let BODY;
let CONTAINER;
const TOAST_CONTAINER_ID = 'toastContainerElement';
const DEFAULTS = {
    display: 'flex',
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    padding: '10px',
};
const addStyles = (element, styles) => {
    const keys = Object.keys(styles);
    for (let i = 0; i < keys.length; ++i) {
        const key = keys[i];
        element.style[key] = styles[key];
    }
};
const getContainerElement = () => {
    const container = document.createElement('div');
    let placement = {
        top: 0
    };
    if (Toast.GLOBAL_CONFIG.placement === 'bottom') {
        placement = {
            bottom: 0
        };
    }
    addStyles(container, Object.assign(Object.assign({ position: 'fixed' }, placement), { left: 0, right: 0, padding: 0, margin: 0, display: 'flex', flex: 1, flexDirection: 'column' }));
    container.id = TOAST_CONTAINER_ID;
    return container;
};
const appendToBody = (element, replace = false) => {
    var _a;
    if (!BODY) {
        BODY = document.body;
    }
    if (!CONTAINER) {
        CONTAINER = getContainerElement();
    }
    if (replace) {
        (_a = document.getElementById(TOAST_CONTAINER_ID)) === null || _a === void 0 ? void 0 : _a.replaceChildren();
    }
    CONTAINER.appendChild(element);
    BODY.replaceChildren(CONTAINER);
};
const handleEvent = (element, onClick) => {
    if (typeof onClick === 'function') {
        element.addEventListener('click', onClick);
    }
};
const handleDisappearing = (element, disappearAfter) => {
    if (disappearAfter !== -1) {
        setTimeout(() => {
            var _a;
            (_a = document.getElementById(TOAST_CONTAINER_ID)) === null || _a === void 0 ? void 0 : _a.removeChild(element);
        }, disappearAfter);
    }
};
const render = (message, props, config) => {
    const toast = document.createElement('div');
    toast.innerHTML = message;
    addStyles(toast, Object.assign(Object.assign({}, DEFAULTS), props));
    handleEvent(toast, config.onClick);
    handleDisappearing(toast, config.disappearAfter);
    appendToBody(toast, !Toast.GLOBAL_CONFIG.stackable);
};
class Toast {
    constructor() {
        throw Error('Instatiation is not permitted');
    }
    static config(props) {
        CONTAINER = null;
        Toast.GLOBAL_CONFIG = Object.assign(Object.assign({}, Toast.GLOBAL_CONFIG), (props || {}));
    }
    static info(message, props = Toast.TOAST_CONFIG) {
        render(message, {
            background: '#f1efff',
            color: '#222e5b'
        }, Object.assign(Object.assign({}, Toast.TOAST_CONFIG), props));
    }
    static error(message, props = Toast.TOAST_CONFIG) {
        render(message, {
            background: '#ffe4e4',
            color: '#ce7d7d'
        }, Object.assign(Object.assign({}, Toast.TOAST_CONFIG), props));
    }
    static success(message, props = Toast.TOAST_CONFIG) {
        render(message, {
            background: '#dcf7e7',
            color: '#4d9a70'
        }, Object.assign(Object.assign({}, Toast.TOAST_CONFIG), props));
    }
}
Toast.GLOBAL_CONFIG = {
    placement: 'top',
    stackable: false
};
Toast.TOAST_CONFIG = {
    disappearAfter: -1,
    onClick: undefined
};

You can view the recorded demo of the final implementation here - https://drive.google.com/file/d/1rBtBGJMWUNQg6NNEkRyJyQ3i19udyx8m/view?usp=share_link

If you like this article, make sure to check out my other articles here. I make sure to post a new article every day. Also, make sure to sign up for the newsletter to directly receive the new articles in your inbox.

Cheers,

Arunkumar Sri Sailapathi.

#2Articles1Week