| 1 | import { |
| 2 | createElement as h, HTMLAttributes, isValidElement, ReactElement, useEffect, useState, useRef, useCallback |
| 3 | } from 'react' |
| 4 | import { proxy, ref, useSnapshot } from 'valtio' |
| 5 | import { AlertType } from './dialog' |
| 6 | import { hIcon, pendingPromise } from './misc' |
| 7 | import _ from 'lodash' |
| 8 | import './toasts.scss' |
| 9 | |
| 10 | type ToastType = AlertType | 'success' |
| 11 | type Content = string | ReactElement |
| 12 | |
| 13 | export function toast(content: Content, type: ToastType='info', { timeout=5_000 }: { timeout?: number } & Omit<ToastOptions, 'id' | 'content' | 'type'>={}) { |
| 14 | console.debug("toast", content) |
| 15 | const id = Math.random() |
| 16 | toasts.push({ |
| 17 | id, |
| 18 | // keep react elements out of valtio proxy snapshots |
| 19 | content: isValidElement(content) ? ref(content) : content, |
| 20 | type: isValidElement(type) ? ref(type) : type, |
| 21 | close |
| 22 | }) |
| 23 | const closed = pendingPromise() |
| 24 | setTimeout(close, timeout) |
| 25 | return { |
| 26 | close, |
| 27 | closed |
| 28 | } |
| 29 | |
| 30 | function close() { |
| 31 | const it = _.find(toasts, { id }) |
| 32 | if (!it) return |
| 33 | it.closed = true |
| 34 | closed.resolve() |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | interface ToastOptions extends Omit<Partial<HTMLAttributes<HTMLDivElement>>, 'id' | 'content'> { |
| 39 | content: Content |
| 40 | type: ToastType |
| 41 | } |
| 42 | interface ToastRecord extends ToastOptions { |
| 43 | id: number |
| 44 | closed?: boolean |
| 45 | close: () => void |
| 46 | } |
| 47 | const toasts = proxy<ToastRecord[]>([]) |
| 48 | |
| 49 | export function Toasts() { |
| 50 | const snap = useSnapshot(toasts) |
| 51 | return h('div', { className: 'toasts' }, |
| 52 | snap.map(d => |
| 53 | h(Toast, { key: d.id, ...(d as any) })) |
| 54 | ) |
| 55 | } |
| 56 | |
| 57 | function Toast({ content, type, closed, id, close, ...props }: ToastRecord) { |
| 58 | const [addClass, setAddClass] = useState('before') |
| 59 | const [height, setHeight] = useState('') |
| 60 | useEffect(() => { |
| 61 | setAddClass(closed ? 'after' : '') |
| 62 | }, [closed]) |
| 63 | const onTransitionEnd = useCallback(() => { |
| 64 | const el = ref.current |
| 65 | if (!addClass && el) // just entered |
| 66 | setHeight(el.clientHeight + 'px') |
| 67 | if (addClass === 'after') |
| 68 | _.remove(toasts, { id }) |
| 69 | }, [addClass]) |
| 70 | const ref = useRef<HTMLDivElement | null>() |
| 71 | return h('div', { |
| 72 | ...props, |
| 73 | ref, |
| 74 | style: { height }, |
| 75 | onTransitionEnd, |
| 76 | onClick: close, |
| 77 | className: `toast ${addClass} ${_.isString(type) ? 'toast-' + type : ''} ${props.className || ''}` |
| 78 | }, |
| 79 | h('div', { className: 'toast-icon' }, isValidElement(type) ? type : hIcon(type)), |
| 80 | h('div', { className: 'toast-content' }, content) |
| 81 | ) |
| 82 | } |