Untitled
unknown
plain_text
a year ago
9.2 kB
6
Indexable
/* eslint-disable no-nested-ternary */ import React, { useEffect } from 'react'; import ReactDOM from 'react-dom'; import styled from 'styled-components'; // Declare enum const Event = { Show: 0, Dismiss: 1 } as const; type Event = (typeof Event)[keyof typeof Event]; const Position = { TopLeft: 'top-left', TopCenter: 'top-center', TopRight: 'top-right', BottomLeft: 'bottom-left', BottomCenter: 'bottom-center', BottomRight: 'bottom-right' } as const; type Position = (typeof Position)[keyof typeof Position]; // Declare type type SonnerProviderConfig = { maxSonner?: number; position?: Position; }; type ToastData = { id?: number; title?: string; desc?: string; duration?: number; }; type ToastDataInternal = Required<ToastData & { dissmiss: boolean }>; type Callback = (...args: any[]) => void; 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, position = Position.TopCenter } = props; const [hovering, setHovering] = React.useState(false); const [toasts, setToasts] = React.useState<ToastDataInternal[]>([]); const [toastHeight] = React.useState<Map<number, number>>(new Map()); const [y, x] = position.split('-'); 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; const styles: any = {}; if (x === 'center') { styles.left = '50%'; styles.transform = 'translateX(-50%)'; } else { styles[x] = 10; } if (!toasts.length) return null; return ( <section data-portal="sonner"> <SonnerToaster data-sonner-toaster onMouseEnter={() => setHovering(true)} onMouseMove={() => setHovering(true)} onMouseLeave={() => setHovering(false)} style={{ '--width': `${356}px`, ...styles, [y]: 10 }} > {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} y={y} /> ); })} </SonnerToaster> </section> ); }; type SonnerItemProps = { toast: ToastDataInternal; remove(id?: number): void; hovering: boolean; total: number; index: number; toastHeight: Map<number, number>; offset: number; y: string; }; const SonnerItem = (props: SonnerItemProps) => { const { toastHeight: mapHeight, toast, remove, hovering, total, index, offset, y } = 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 direction = y === 'top' ? -1 : 1; const transform = mounted ? removed ? `translateY(${100 * direction}%)` : hovering ? `translateY(${-direction * (offset + 10 * index)}px)` : `translateY(${-direction * index * 10}px) scale(${-index * 0.05 + 1})` : `translateY(${100 * direction}%)`; 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', [y]: 0 }} ref={toastRef} > <ToastItem>{toast.title}</ToastItem> </LiItem> ); }; const SonnerToaster = styled.ol` list-style: none; margin: 0; padding: 0; z-index: 999; position: fixed; width: var(--width); @media (max-width: 600px) { [data-sonner-toaster] { position: fixed; --mobile-offset: 16px; right: var(--mobile-offset); left: var(--mobile-offset); width: 100%; } [data-sonner-toaster] [data-sonner-toast] { left: 0; right: 0; width: calc(100% - 32px); } /* [data-sonner-toaster][data-x-position='left'] { left: var(--mobile-offset); } [data-sonner-toaster][data-y-position='bottom'] { bottom: 20px; } [data-sonner-toaster][data-y-position='top'] { top: 20px; } [data-sonner-toaster][data-x-position='center'] { left: var(--mobile-offset); right: var(--mobile-offset); transform: none; } */ } `; 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