[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:
Global level (It affects all the toast notifications)
Toast level (It only affects a specific toast notification)
For the Global level customization, we can have fields like:
Should Stack notifications?
Placement
Top
Bottom
For the Toast level customization, we can have fields like:
Should automatically disappear?
- If yes, after how long?
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.