Untitled
unknown
plain_text
a year ago
7.6 kB
7
Indexable
/* eslint-disable no-nested-ternary */ import React, { useEffect } from 'react'; import ReactDOM from 'react-dom'; import styled from 'styled-components'; type SonnerProviderConfig = { maxSonner?: number; }; type ToastData = { id?: number; title?: string; desc?: string; duration?: number; }; type ToastDataInternal = Required<ToastData & { dissmiss: boolean }>; type Callback = (...args: any[]) => void; const Event = { Show: 0, Dismiss: 1 } as const; type Event = (typeof Event)[keyof typeof Event]; class EventManager { list = new Map<Event, Callback[]>(); on(event: Event, callback: Callback) { this.list.has(event) || this.list.set(event, []); this.list.get(event)!.push(callback); return this; } off(event: Event, callback: Callback) { if (callback) { const cb = this.list.get(event)!.filter(cb => cb !== callback); this.list.set(event, cb); return this; } this.list.delete(event); return this; } emit(event: Event, ...args: any[]) { this.list.has(event) && this.list.get(event)!.forEach((callback: Callback) => { callback(...args); }); } } const eventManager = new EventManager(); const DEFAULT_DURATION = 3000; let id = 0; function generateId() { id += 1; return id; } function sonner(toast: ToastData): void; function sonner(title: string, toastData?: ToastData): void; function sonner(titleOrToast: ToastData | string, toastData: ToastData = {}) { let data: any; if (typeof titleOrToast === 'string') data = { ...toastData, id: toastData.id || generateId(), title: titleOrToast }; else data = { ...titleOrToast, id: titleOrToast.id || generateId() }; eventManager.emit(Event.Show, data); return data.id as number; } function dismiss(id?: number) { eventManager.emit(Event.Dismiss, id); } const toast = Object.assign(sonner, { dismiss }); const SonnerProvider = ( props: React.PropsWithChildren<SonnerProviderConfig> ) => { const { maxSonner = 10 } = props; const [hovering, setHovering] = React.useState(false); const [toasts, setToasts] = React.useState<ToastDataInternal[]>([]); const [toastHeight] = React.useState<Map<number, number>>(new Map()); const showToast = React.useCallback( (toastData: ToastDataInternal) => { ReactDOM.flushSync(() => setToasts(toasts => { const indexOfExistingToast = toasts.findIndex( t => t.id === toastData.id ); // Update the toast if it already exists if (indexOfExistingToast !== -1) { return [ ...toasts.slice(0, indexOfExistingToast), { ...toasts[indexOfExistingToast], ...sonner }, ...toasts.slice(indexOfExistingToast + 1) ]; } return [toastData, ...toasts].slice(0, maxSonner); }) ); }, [maxSonner] ); const removeToast = React.useCallback((id: number) => { toastHeight.delete(id); setToasts(toasts => toasts.filter(toast => toast.id !== id)); }, []); const dissmissToast = React.useCallback( (id?: number) => id ? setToasts(toasts => toasts.map(t => (t.id === id ? { ...t, dissmiss: true } : t)) ) : setToasts(toasts => toasts.map(t => { // eslint-disable-next-line no-param-reassign t.dissmiss = true; return t; }) ), [] ); useEffect(() => { eventManager.on(Event.Show, showToast).on(Event.Dismiss, dissmissToast); return () => { eventManager.off(Event.Show, showToast).off(Event.Dismiss, dissmissToast); }; }, [showToast, dissmissToast]); const total = toasts.length; let offset = 0; if (!toasts.length) return null; return ( <section data-portal="sonner" style={{ zIndex: 999, position: 'fixed' }}> <ol data-sonner-toaster onMouseEnter={() => setHovering(true)} onMouseMove={() => setHovering(true)} onMouseLeave={() => setHovering(false)} style={{ listStyle: 'none', margin: 0 }} > {toasts.map((toast, index) => { const currentOffset = offset; offset += toastHeight.get(toast.id) || 0; return ( <SonnerItem key={toast.id} remove={removeToast} toast={toast} hovering={hovering} total={total} index={index} toastHeight={toastHeight} offset={currentOffset} /> ); })} </ol> </section> ); }; type SonnerItemProps = { toast: ToastDataInternal; remove(id?: number): void; hovering: boolean; total: number; index: number; toastHeight: Map<number, number>; offset: number; }; const SonnerItem = (props: SonnerItemProps) => { const { toastHeight: mapHeight, toast, remove, hovering, total, index, offset } = props; const [mounted, setMounted] = React.useState(false); const [removed, setRemoved] = React.useState(false); const toastRef = React.useRef<HTMLLIElement>(null); const isVisible = index <= 2; const deleteToast = React.useCallback(() => { setRemoved(true); setTimeout(() => { remove(toast.id); }, 200); }, [toast]); React.useEffect(() => { setMounted(true); }, []); React.useLayoutEffect(() => { if (!mounted) return; const toastNode = toastRef.current!; const originalHeight = toastNode.style.height; toastNode.style.height = 'auto'; const toastHeight = toastNode.getBoundingClientRect().height; toastNode.style.height = originalHeight; mapHeight.set(toast.id, toastHeight); }, [mounted, toast.title, toast.desc, toast.id]); React.useEffect(() => { let timeoutId: NodeJS.Timeout; if (!hovering) { timeoutId = setTimeout(deleteToast, toast.duration || DEFAULT_DURATION); } return () => { clearTimeout(timeoutId); }; }, [hovering]); React.useEffect(() => { if (toast.dissmiss) deleteToast(); }, [deleteToast, toast.dissmiss]); const transform = mounted ? removed ? 'translateY(-100%)' : hovering ? `translateY(${offset + 10 * index}px)` : `translateY(${index * 10}px) scale(${-index * 0.05 + 1})` : 'translateY(-100%)'; return ( <LiItem data-mounted={mounted} key={`toast_item_${toast.id}`} data-sonner-id={toast.id} style={{ zIndex: total - index, transform, opacity: removed ? 0 : isVisible ? Number(mounted) : 0, pointerEvents: isVisible ? 'auto' : 'none' }} ref={toastRef} > <ToastItem>{toast.title}</ToastItem> </LiItem> ); }; const LiItem = styled.li` position: absolute; transition: transform 400ms, opacity 400ms, height 400ms, box-shadow 200ms; list-style: none; width: 356px; ::after { content: ''; position: absolute; bottom: 100%; width: 100%; left: 0; height: 10px; } `; const ToastItem = styled.div` padding: 1rem; border: 1px solid black; border-radius: 0.25rem; background: white; border: 1px solid hsl(0, 0%, 93%); box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1); `; export default SonnerProvider; export { toast as sonner };
Editor is loading...
Leave a Comment