Untitled
unknown
plain_text
2 years ago
7.6 kB
16
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