2023-12-05 10:09:33 -05:00
|
|
|
// Copyright (c) Tailscale Inc & AUTHORS
|
|
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
import cx from "classnames"
|
|
|
|
import React, {
|
|
|
|
forwardRef,
|
|
|
|
useCallback,
|
|
|
|
useEffect,
|
|
|
|
useMemo,
|
|
|
|
useState,
|
|
|
|
} from "react"
|
|
|
|
import { createPortal } from "react-dom"
|
2024-02-22 13:16:44 -07:00
|
|
|
import X from "src/assets/icons/x.svg?react"
|
2023-12-05 10:09:33 -05:00
|
|
|
import { noop } from "src/utils/util"
|
|
|
|
import { create } from "zustand"
|
|
|
|
import { shallow } from "zustand/shallow"
|
|
|
|
|
|
|
|
// Set up root element on the document body for toasts to render into.
|
|
|
|
const root = document.createElement("div")
|
|
|
|
root.id = "toast-root"
|
|
|
|
root.classList.add("relative", "z-20")
|
|
|
|
document.body.append(root)
|
|
|
|
|
|
|
|
const toastSpacing = remToPixels(1)
|
|
|
|
|
|
|
|
export type Toaster = {
|
|
|
|
clear: () => void
|
|
|
|
dismiss: (key: string) => void
|
|
|
|
show: (props: Toast) => string
|
|
|
|
}
|
|
|
|
|
|
|
|
type Toast = {
|
|
|
|
key?: string // key is a unique string value that ensures only one toast with a given key is shown at a time.
|
|
|
|
className?: string
|
|
|
|
variant?: "danger" // styling for the toast, undefined is neutral, danger is for failed requests
|
|
|
|
message: React.ReactNode
|
|
|
|
timeout?: number
|
|
|
|
added?: number // timestamp of when the toast was added
|
|
|
|
}
|
|
|
|
|
|
|
|
type ToastWithKey = Toast & { key: string }
|
|
|
|
|
|
|
|
type State = {
|
|
|
|
toasts: ToastWithKey[]
|
|
|
|
maxToasts: number
|
|
|
|
clear: () => void
|
|
|
|
dismiss: (key: string) => void
|
|
|
|
show: (props: Toast) => string
|
|
|
|
}
|
|
|
|
|
|
|
|
const useToasterState = create<State>((set, get) => ({
|
|
|
|
toasts: [],
|
|
|
|
maxToasts: 5,
|
|
|
|
clear: () => {
|
|
|
|
set({ toasts: [] })
|
|
|
|
},
|
|
|
|
dismiss: (key: string) => {
|
|
|
|
set((prev) => ({
|
|
|
|
toasts: prev.toasts.filter((t) => t.key !== key),
|
|
|
|
}))
|
|
|
|
},
|
|
|
|
show: (props: Toast) => {
|
|
|
|
const { toasts: prevToasts, maxToasts } = get()
|
|
|
|
|
|
|
|
const propsWithKey = {
|
|
|
|
key: Date.now().toString(),
|
|
|
|
...props,
|
|
|
|
}
|
|
|
|
const prevIdx = prevToasts.findIndex((t) => t.key === propsWithKey.key)
|
|
|
|
|
|
|
|
// If the toast already exists, update it. Otherwise, append it.
|
|
|
|
const nextToasts =
|
|
|
|
prevIdx !== -1
|
|
|
|
? [
|
|
|
|
...prevToasts.slice(0, prevIdx),
|
|
|
|
propsWithKey,
|
|
|
|
...prevToasts.slice(prevIdx + 1),
|
|
|
|
]
|
|
|
|
: [...prevToasts, propsWithKey]
|
|
|
|
|
|
|
|
set({
|
|
|
|
// Get the last `maxToasts` toasts of the set.
|
|
|
|
toasts: nextToasts.slice(-maxToasts),
|
|
|
|
})
|
|
|
|
return propsWithKey.key
|
|
|
|
},
|
|
|
|
}))
|
|
|
|
|
|
|
|
const clearSelector = (state: State) => state.clear
|
|
|
|
|
|
|
|
const toasterSelector = (state: State) => ({
|
|
|
|
show: state.show,
|
|
|
|
dismiss: state.dismiss,
|
|
|
|
clear: state.clear,
|
|
|
|
})
|
|
|
|
|
|
|
|
/**
|
|
|
|
* useRawToasterForHook is meant to supply the hook function for hooks/toaster.
|
|
|
|
* Use hooks/toaster instead.
|
|
|
|
*/
|
|
|
|
export const useRawToasterForHook = () =>
|
|
|
|
useToasterState(toasterSelector, shallow)
|
|
|
|
|
|
|
|
type ToastProviderProps = {
|
|
|
|
children: React.ReactNode
|
|
|
|
canEscapeKeyClear?: boolean
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* ToastProvider is the top-level toaster component. It stores the toast state.
|
|
|
|
*/
|
|
|
|
export default function ToastProvider(props: ToastProviderProps) {
|
|
|
|
const { children, canEscapeKeyClear = true } = props
|
|
|
|
const clear = useToasterState(clearSelector)
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
function handleKeyDown(e: KeyboardEvent) {
|
|
|
|
if (!canEscapeKeyClear) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if (e.key === "Esc" || e.key === "Escape") {
|
|
|
|
clear()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
window.addEventListener("keydown", handleKeyDown)
|
|
|
|
return () => {
|
|
|
|
window.removeEventListener("keydown", handleKeyDown)
|
|
|
|
}
|
|
|
|
}, [canEscapeKeyClear, clear])
|
|
|
|
|
|
|
|
return (
|
|
|
|
<>
|
|
|
|
{children}
|
|
|
|
<ToastContainer />
|
|
|
|
</>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
const toastContainerSelector = (state: State) => ({
|
|
|
|
toasts: state.toasts,
|
|
|
|
dismiss: state.dismiss,
|
|
|
|
})
|
|
|
|
|
|
|
|
/**
|
|
|
|
* ToastContainer manages the positioning and animation for all currently
|
|
|
|
* displayed toasts. It should only be used by ToastProvider.
|
|
|
|
*/
|
|
|
|
function ToastContainer() {
|
|
|
|
const { toasts, dismiss } = useToasterState(toastContainerSelector, shallow)
|
|
|
|
|
|
|
|
const [prevToasts, setPrevToasts] = useState<ToastWithKey[]>(toasts)
|
|
|
|
useEffect(() => setPrevToasts(toasts), [toasts])
|
|
|
|
|
|
|
|
const [refMap] = useState(() => new Map<string, HTMLDivElement>())
|
|
|
|
const getOffsetForToast = useCallback(
|
|
|
|
(key: string) => {
|
|
|
|
let offset = 0
|
|
|
|
|
|
|
|
let arr = toasts
|
|
|
|
let index = arr.findIndex((t) => t.key === key)
|
|
|
|
if (index === -1) {
|
|
|
|
arr = prevToasts
|
|
|
|
index = arr.findIndex((t) => t.key === key)
|
|
|
|
}
|
|
|
|
|
|
|
|
if (index === -1) {
|
|
|
|
return offset
|
|
|
|
}
|
|
|
|
|
|
|
|
for (let i = arr.length; i > index; i--) {
|
|
|
|
if (!arr[i]) {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
const ref = refMap.get(arr[i].key)
|
|
|
|
if (!ref) {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
offset -= ref.offsetHeight
|
|
|
|
offset -= toastSpacing
|
|
|
|
}
|
|
|
|
return offset
|
|
|
|
},
|
|
|
|
[refMap, prevToasts, toasts]
|
|
|
|
)
|
|
|
|
|
|
|
|
const toastsWithStyles = useMemo(
|
|
|
|
() =>
|
|
|
|
toasts.map((toast) => ({
|
|
|
|
toast: toast,
|
|
|
|
style: {
|
|
|
|
transform: `translateY(${getOffsetForToast(toast.key)}px) scale(1.0)`,
|
|
|
|
},
|
|
|
|
})),
|
|
|
|
[getOffsetForToast, toasts]
|
|
|
|
)
|
|
|
|
|
|
|
|
if (!root) {
|
|
|
|
throw new Error("Could not find toast root") // should never happen
|
|
|
|
}
|
|
|
|
|
|
|
|
return createPortal(
|
|
|
|
<div className="fixed bottom-6 right-6 z-[99]">
|
|
|
|
{toastsWithStyles.map(({ toast, style }) => (
|
|
|
|
<ToastBlock
|
|
|
|
key={toast.key}
|
|
|
|
ref={(ref) => ref && refMap.set(toast.key, ref)}
|
|
|
|
toast={toast}
|
|
|
|
onDismiss={dismiss}
|
|
|
|
style={style}
|
|
|
|
/>
|
|
|
|
))}
|
|
|
|
</div>,
|
|
|
|
root
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* ToastBlock is the display of an individual toast, and also manages timeout
|
|
|
|
* settings for a particular toast.
|
|
|
|
*/
|
|
|
|
const ToastBlock = forwardRef<
|
|
|
|
HTMLDivElement,
|
|
|
|
{
|
|
|
|
toast: ToastWithKey
|
|
|
|
onDismiss?: (key: string) => void
|
|
|
|
style?: React.CSSProperties
|
|
|
|
}
|
|
|
|
>(({ toast, onDismiss = noop, style }, ref) => {
|
|
|
|
const { message, key, timeout = 5000, variant } = toast
|
|
|
|
|
|
|
|
const [focused, setFocused] = useState(false)
|
|
|
|
const dismiss = useCallback(() => onDismiss(key), [onDismiss, key])
|
|
|
|
const onFocus = useCallback(() => setFocused(true), [])
|
|
|
|
const onBlur = useCallback(() => setFocused(false), [])
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
if (timeout <= 0 || focused) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
const timerId = setTimeout(() => dismiss(), timeout)
|
|
|
|
return () => clearTimeout(timerId)
|
|
|
|
}, [dismiss, timeout, focused])
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div
|
|
|
|
className={cx(
|
|
|
|
"transition ease-in-out animate-scale-in",
|
|
|
|
"bottom-0 right-0 z-[99] w-[85vw] origin-bottom",
|
|
|
|
"sm:min-w-[400px] sm:max-w-[500px]",
|
|
|
|
"absolute shadow-sm rounded-md text-md flex items-center justify-between",
|
|
|
|
{
|
|
|
|
"text-white bg-gray-700": variant === undefined,
|
|
|
|
"text-white bg-orange-400": variant === "danger",
|
|
|
|
}
|
|
|
|
)}
|
|
|
|
aria-live="polite"
|
|
|
|
ref={ref}
|
|
|
|
onBlur={onBlur}
|
|
|
|
onFocus={onFocus}
|
|
|
|
onMouseEnter={onFocus}
|
|
|
|
onMouseLeave={onBlur}
|
|
|
|
tabIndex={0}
|
|
|
|
style={style}
|
|
|
|
>
|
|
|
|
<span className="pl-4 py-3 pr-2">{message}</span>
|
|
|
|
<button
|
|
|
|
className="cursor-pointer opacity-75 hover:opacity-50 transition-opacity py-3 px-3"
|
|
|
|
onClick={dismiss}
|
|
|
|
>
|
|
|
|
<X className="w-[1em] h-[1em] stroke-current" />
|
|
|
|
</button>
|
|
|
|
</div>
|
|
|
|
)
|
|
|
|
})
|
|
|
|
|
|
|
|
function remToPixels(rem: number) {
|
|
|
|
return (
|
|
|
|
rem * Number.parseFloat(getComputedStyle(document.documentElement).fontSize)
|
|
|
|
)
|
|
|
|
}
|