Last active
March 25, 2021 10:04
-
-
Save andreasvirkus/5de84fa7d8674faa2717ae33da16ce3e to your computer and use it in GitHub Desktop.
Vanilla JS Toast component
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
.toaster { | |
display: block; | |
position: fixed; | |
left: 50%; | |
transform: translateX(-50%); | |
bottom: 16px; | |
width: 100%; | |
height: 0; | |
z-index: 11; | |
} | |
.toast { | |
display: flex; | |
position: fixed; | |
bottom: 0; | |
left: 50%; | |
align-items: center; | |
transform-origin: center; | |
will-change: transform; | |
transition: transform 300ms ease, opacity 300ms ease; | |
font-size: 14px; | |
padding-bottom: 10px; | |
} | |
.toast[aria-hidden='false'] { | |
animation: snackbar-show 300ms ease 1; | |
} | |
.toast[aria-hidden='true'] { | |
animation: snackbar-hide 300ms ease forwards 1; | |
} | |
.toast__container { | |
display: flex; | |
align-items: center; | |
position: relative; | |
padding: 10px 16px; | |
font-size: 14px; | |
min-width: 280px; | |
border-radius: 8px; | |
color: white; | |
font-weight: 500; | |
background-color: var(--toast-color, var(--brand)); | |
box-shadow: var(--shadow-lg); | |
} | |
.toast__text { | |
flex: 1 1 auto; | |
font-size: 100%; | |
} | |
.toast__text:not(:last-child) { | |
padding-right: 8px; | |
} | |
.toast__button { | |
font-weight: 500; | |
border-bottom: 1px solid; | |
} | |
.toast__close { | |
position: relative; | |
display: block; | |
height: 24px; | |
width: 24px; | |
color: white; | |
border: none; | |
font-size: 24px; | |
line-height: 1; | |
transform: translateY(-1px); | |
z-index: 1; | |
} | |
.toast__close::before { | |
content: ''; | |
position: absolute; | |
top: 1px; | |
left: 0; | |
height: 24px; | |
width: 24px; | |
background-color: var(--gray-90); | |
opacity: 0.2; | |
border-radius: 50%; | |
z-index: -1; | |
} | |
.toast__close:hover::before { | |
opacity: 0.4; | |
} | |
@keyframes snackbar-show { | |
from { | |
opacity: 0; | |
transform: translate3d(0, 100%, 0); | |
} | |
} | |
@keyframes snackbar-hide { | |
to { | |
opacity: 0; | |
transform: translateY(100%); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import './style.css' | |
interface ToastOptions { | |
status?: 'info' | 'error' | 'warning' | 'success' | |
message: string | |
actionLabel?: string | |
timeout?: number | |
maxStack?: number | |
callback?: () => void | |
} | |
export interface ToastResult { | |
destroy: () => void | |
} | |
const instances: Toast[] = [] | |
let instanceStackStatus = true | |
const defaultMaxStack = 3 | |
const defaultStatus = 'info' | |
const statusColorMap = { | |
info: '--brand-50', | |
error: '--red-50', | |
success: '--green-50', | |
warning: '--orange-50', | |
} | |
class Toast { | |
options: ToastOptions | |
wrapper: HTMLDivElement | |
el?: HTMLDivElement | |
private timeoutId?: number | |
private visibilityTimeoutId?: number | |
constructor(options: ToastOptions) { | |
this.options = Object.assign( | |
{}, | |
{ | |
status: defaultStatus, | |
timeout: 3000, | |
maxStack: defaultMaxStack, | |
actionLabel: '', | |
}, | |
options, | |
) | |
this.wrapper = this.getWrapper() | |
this.insert() | |
instances.push(this) | |
this.stack() | |
} | |
getWrapper(): HTMLDivElement { | |
let wrapper = document.querySelector(`.toaster`) as HTMLDivElement | |
if (!wrapper) { | |
wrapper = document.createElement('div') | |
wrapper.className = `toaster` | |
document.body.appendChild(wrapper) | |
} | |
return wrapper | |
} | |
insert() { | |
const el = document.createElement('div') | |
el.className = 'toast' | |
el.setAttribute('aria-live', 'assertive') | |
el.setAttribute('aria-atomic', 'true') | |
el.setAttribute('aria-hidden', 'false') | |
el.style.setProperty('--toast-color', `var(${statusColorMap[this.options.status || defaultStatus]})`) | |
// Container for center-alignment and space between stacked toasts | |
const container = document.createElement('div') | |
container.className = 'toast__container' | |
el.appendChild(container) | |
const text = document.createElement('div') | |
text.className = 'toast__text' | |
if (typeof this.options.message === 'string') { | |
text.textContent = this.options.message | |
} else { | |
text.appendChild(this.options.message) | |
} | |
container.appendChild(text) | |
const { actionLabel, callback } = this.options | |
const button = document.createElement('button') | |
button.className = 'toast__button' | |
button.innerHTML = actionLabel || '×' | |
if (!actionLabel) button.classList.add('toast__close') | |
button.addEventListener('click', () => { | |
this.stopTimer() | |
if (callback) callback() | |
this.destroy() | |
}) | |
container.appendChild(button) | |
this.startTimer() | |
el.addEventListener('mouseenter', () => this.expand()) | |
el.addEventListener('mouseleave', () => this.stack()) | |
this.el = el | |
this.wrapper.appendChild(el) | |
} | |
stack() { | |
instanceStackStatus = true | |
const positionInstances = instances | |
const l = positionInstances.length - 1 | |
positionInstances.forEach((instance, i) => { | |
// Resume all instances' timers if applicable | |
instance.startTimer() | |
const { el } = instance | |
if (el) { | |
el.style.transform = `translate3d(0, -${(l - i) * 15}px, -${l - i}px) scale(${1 - 0.05 * (l - i)})` | |
const hidden = l - i >= (this.options.maxStack || defaultMaxStack) | |
this.toggleVisibility(el, hidden) | |
} | |
}) | |
} | |
expand() { | |
instanceStackStatus = false | |
const positionInstances = instances | |
const l = positionInstances.length - 1 | |
positionInstances.forEach((instance, i) => { | |
// Stop all instances' timers to prevent destroy | |
instance.stopTimer() | |
const { el } = instance | |
if (el) { | |
el.style.transform = `translate3d(0, -${(l - i) * el.clientHeight}px, 0) scale(1)` | |
const hidden = l - i >= (this.options.maxStack || defaultMaxStack) | |
this.toggleVisibility(el, hidden) | |
} | |
}) | |
} | |
toggleVisibility(el: HTMLDivElement, hidden: boolean) { | |
if (hidden) { | |
this.visibilityTimeoutId = window.setTimeout(() => { | |
el.style.visibility = 'hidden' | |
}, 300) | |
el.style.opacity = '0' | |
} else { | |
if (this.visibilityTimeoutId) { | |
clearTimeout(this.visibilityTimeoutId) | |
this.visibilityTimeoutId = undefined | |
} | |
el.style.opacity = '1' | |
el.style.visibility = 'visible' | |
} | |
} | |
/** | |
* Destory the toast | |
*/ | |
async destroy() { | |
const { el, wrapper } = this | |
if (el) { | |
// Animate the toast away. | |
el.setAttribute('aria-hidden', 'true') | |
await new Promise<void>((resolve) => el.addEventListener('animationend', () => resolve())) | |
wrapper.removeChild(el) | |
// Remove instance from the instances array | |
const positionInstances = instances | |
let index: number | undefined = undefined | |
for (let i = 0; i < positionInstances.length; i++) { | |
if (positionInstances[i].el === el) { | |
index = i | |
break | |
} | |
} | |
if (index !== undefined) positionInstances.splice(index, 1) | |
// Based on current status, refresh stack or expand style | |
if (instanceStackStatus) this.stack() | |
else this.expand() | |
} | |
} | |
startTimer() { | |
if (this.options.timeout && !this.timeoutId) { | |
this.timeoutId = self.setTimeout(() => this.destroy(), this.options.timeout) | |
} | |
} | |
stopTimer() { | |
if (this.timeoutId) { | |
clearTimeout(this.timeoutId) | |
this.timeoutId = undefined | |
} | |
} | |
} | |
export const toast = (options: ToastOptions) => new Toast(options) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment