Skip to content

Instantly share code, notes, and snippets.

@lior-amsalem
Created December 19, 2024 13:26
Show Gist options
  • Save lior-amsalem/cfe9161ffb7e5e0f6e4ac1fb0dd8f275 to your computer and use it in GitHub Desktop.
Save lior-amsalem/cfe9161ffb7e5e0f6e4ac1fb0dd8f275 to your computer and use it in GitHub Desktop.
written in typescript and reactjs and typescript heres a toast
.ToastViewport {
--stack-gap: 10px;
position: fixed;
bottom: 0;
right: 0;
width: 390px;
max-width: 100vw;
margin: 0;
list-style: none;
z-index: 2147483647;
outline: none;
transition: transform 400ms ease;
}
.ToastRoot {
--opacity: 0;
--x: var(--radix-toast-swipe-move-x, 0);
--y: calc(1px - (var(--stack-gap) * var(--index)));
--scale: calc(1 - 0.05 * var(--index));
position: absolute;
bottom: 15px;
right: 15px;
left: 15px;
transition-property: transform, opacity;
transition-duration: 400ms;
transition-timing-function: ease;
opacity: var(--opacity);
transform: translate3d(var(--x), 85px, 0);
outline: none;
border-radius: 5px;
}
.ToastRoot:focus-visible {
box-shadow: 0 0 0 2px black;
}
.ToastRoot:after {
content: "";
position: absolute;
left: 0;
right: 0;
top: 100%;
width: 100%;
height: 1000px;
background: transparent;
}
.ToastRoot[data-front="true"] {
transform: translate3d(var(--x), var(--y, 0), 0);
}
.ToastRoot[data-front="false"] {
transform: translate3d(var(--x), var(--y, 0), 0) scale(var(--scale));
}
.ToastRoot[data-state="closed"] {
animation: slideDown 350ms ease;
}
.ToastRoot[data-hidden="false"] {
--opacity: 1;
}
.ToastRoot[data-hidden="true"] {
--opacity: 0;
}
.ToastRoot[data-hovering="true"] {
--scale: 1;
--y: calc(var(--hover-offset-y) - var(--stack-gap) * var(--index));
transition-duration: 350ms;
}
.ToastRoot[data-swipe="move"] {
transition-duration: 0ms;
}
.ToastRoot[data-swipe="cancel"] {
--x: 0;
}
.ToastRoot[data-swipe-direction="right"][data-swipe="end"] {
animation: slideRight 150ms ease-out;
}
.ToastRoot[data-swipe-direction="left"][data-swipe="end"] {
animation: slideLeft 150ms ease-out;
}
@keyframes slideDown {
from {
transform: translate3d(0, var(--y), 0);
}
to {
transform: translate3d(0, 85px, 0);
}
}
@keyframes slideRight {
from {
transform: translate3d(var(--radix-toast-swipe-end-x), var(--y), 0);
}
to {
transform: translate3d(100%, var(--y), 0);
}
}
@keyframes slideLeft {
from {
transform: translate3d(var(--radix-toast-swipe-end-x), var(--y), 0);
}
to {
transform: translate3d(-100%, var(--y), 0);
}
}
.ToastInner {
padding: 15px;
border-radius: 5px;
height: var(--height);
background-color: white;
box-shadow: hsl(206 22% 7% / 35%) 0px 10px 38px -10px,
hsl(206 22% 7% / 20%) 0px 10px 20px -15px;
display: grid;
grid-template-areas: "title action" "description action";
grid-template-columns: auto max-content;
column-gap: 10px;
align-items: center;
position: relative;
}
.ToastInner:not([data-status="default"]) {
grid-template-areas: "icon title action" "icon description action";
grid-template-columns: max-content auto max-content;
}
.ToastInner:not([data-front="true"]) {
height: var(--front-height);
}
.ToastRoot[data-hovering="true"] .ToastInner {
height: var(--height);
}
.ToastTitle {
grid-area: title;
margin-bottom: 5px;
font-weight: 500;
color: var(--slate12);
font-size: 15px;
}
.ToastDescription {
grid-area: description;
margin: 0;
color: var(--slate11);
font-size: 13px;
line-height: 1.3;
}
.ToastAction {
grid-area: action;
}
.ToastClose {
position: absolute;
left: 0px;
top: 0px;
transform: translate(-35%, -35%);
width: 15px;
height: 15px;
padding: 0px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background-color: var(--slate1);
color: var(--slate11);
transition: color 200ms ease 0s, opacity 200ms ease 0s;
opacity: 0;
box-shadow: rgb(0 0 0 / 16%) 0px 0px 8px;
}
.ToastClose:hover {
color: var(--slate12);
}
.ToastInner:hover .ToastClose {
opacity: 1;
}
.checkmark {
width: 20px;
opacity: 0;
height: 20px;
border-radius: 10px;
background-color: #61d345;
position: relative;
transform: rotate(45deg);
animation: circleAnimation 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)
forwards;
animation-delay: 100ms;
}
.checkmark::after {
content: "";
box-sizing: border-box;
animation: checkmarkAnimation 0.2s ease-out forwards;
opacity: 0;
animation-delay: 200ms;
position: absolute;
border-right: 2px solid;
border-bottom: 2px solid;
border-color: #fff;
bottom: 6px;
left: 6px;
height: 10px;
width: 6px;
}
.error {
width: 20px;
opacity: 0;
height: 20px;
border-radius: 10px;
background-color: #ff4b4b;
position: relative;
transform: rotate(45deg);
animation: circleAnimation 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)
forwards;
animation-delay: 100ms;
}
.error::before,
.error::after {
content: "";
animation: firstLineAnimation 0.15s ease-out forwards;
animation-delay: 150ms;
position: absolute;
border-radius: 3px;
opacity: 0;
background-color: #fff;
bottom: 9px;
left: 4px;
height: 2px;
width: 12px;
}
.error::before {
animation: secondLineAnimation 0.15s ease-out forwards;
animation-delay: 180ms;
transform: rotate(90deg);
}
@keyframes circleAnimation {
from {
transform: scale(0) rotate(45deg);
opacity: 0;
}
to {
transform: scale(1) rotate(45deg);
opacity: 1;
}
}
@keyframes checkmarkAnimation {
0% {
height: 0;
width: 0;
opacity: 0;
}
40% {
height: 0;
width: 6px;
opacity: 1;
}
100% {
opacity: 1;
height: 10px;
}
}
@keyframes firstLineAnimation {
from {
transform: scale(0);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
@keyframes secondLineAnimation {
from {
transform: scale(0) rotate(90deg);
opacity: 0;
}
to {
transform: scale(1) rotate(90deg);
opacity: 1;
}
}
/** ------------------------------------------------------- **
FILE: Toast msgs ui for with radix
- there's a provider at the top level of the application
How to:
const Comp = () => {
const toast = useToast();
useEffect(() => {
// default:
toast({ description: 'test me now' })
// error:
toast.error({ title: 'Network Error!', description: 'Failed to update, Please try again.' })
}, []);
return <div>test</div>
}
** ------------------------------------------------------- **/
import * as React from "react";
import * as ToastPrimitive from "@radix-ui/react-toast";
import { Cross2Icon } from "@radix-ui/react-icons";
import { CheckmarkIcon } from "./checkmark-icon";
import { ErrorIcon } from "./error-icon";
import './style.css';
type ToastType = Payload & {status: string}
interface Payload {
[key: string]: any; // Keys are strings, values can be of any type
}
type ToastFunction = (message: Payload) => void;
interface ToastContextType extends ToastFunction {
success: (payload: Payload) => void;
error: (payload: Payload) => void;
}
const ToastContext = React.createContext<ToastContextType>(
Object.assign(() => null, {
success: () => null,
error: () => null,
})
);
interface ToastContextImplType {// extends ToastImplFunction {
toastElementsMapRef: React.MutableRefObject<Map<any, any>>
sortToasts: () => void;
}
const ToastContextImpl = React.createContext<ToastContextImplType>(
Object.assign(() => null, {
toastElementsMapRef: { current: new Map() } as React.MutableRefObject<Map<any, any>>,
sortToasts: () => null,
})
);
const ANIMATION_OUT_DURATION = 350;
interface ToastsProviderProps {
children: React.ReactNode; // The children can be any valid React node
[key: string]: any; // This allows for any other props to be passed, but we should be more specific if needed
}
export const ToastsProvider:React.FC<ToastsProviderProps> = ({ children, ...props }) => {
const [toasts, setToasts] = React.useState(new Map()); // of Payload and status
const toastElementsMapRef = React.useRef<Map<any, any>>(new Map());
const viewportRef = React.useRef<HTMLOListElement>(null);
const sortToasts = React.useCallback(() => {
const toastElements = Array.from(toastElementsMapRef.current).reverse();
const heights: Array<number> = [];
toastElements.forEach(([, toast], index) => {
if (!toast) return;
const height = toast.clientHeight;
heights.push(height);
const frontToastHeight = heights[0];
toast.setAttribute("data-front", index === 0);
toast.setAttribute("data-hidden", index > 2);
toast.style.setProperty("--index", index);
toast.style.setProperty("--height", `${height}px`);
toast.style.setProperty("--front-height", `${frontToastHeight}px`);
const hoverOffsetY = heights
.slice(0, index)
.reduce((res, next) => (res += next), 0);
toast.style.setProperty("--hover-offset-y", `-${hoverOffsetY}px`);
});
}, []);
const handleAddToast = React.useCallback((toast: ToastType) => {
setToasts((currentToasts) => {
const newMap = new Map(currentToasts);
newMap.set(String(Date.now()), { ...toast, open: true });
return newMap;
});
}, []);
const handleRemoveToast = React.useCallback((key: number) => {
setToasts((currentToasts) => {
const newMap = new Map(currentToasts);
newMap.delete(key);
return newMap;
});
}, []);
const handleDispatchDefault = React.useCallback(
(payload: Payload) => handleAddToast({ ...payload, status: "default" }),
[handleAddToast]
);
const handleDispatchSuccess = React.useCallback(
(payload: Payload) => handleAddToast({ ...payload, status: "success" }),
[handleAddToast]
);
const handleDispatchError = React.useCallback(
(payload: Payload) => handleAddToast({ ...payload, status: "error" }),
[handleAddToast]
);
React.useEffect(() => {
const viewport = viewportRef.current;
if (viewport) {
const handleFocus = () => {
toastElementsMapRef.current.forEach((toast) => {
toast.setAttribute("data-hovering", "true");
});
};
const handleBlur = (event: FocusEvent | PointerEvent) => {
// let's verify the type
if (viewport && event.target instanceof Node && !viewport.contains(event.target) || viewport === event.target) {
toastElementsMapRef.current.forEach((toast) => {
toast.setAttribute("data-hovering", "false");
});
}
};
viewport.addEventListener("pointermove", handleFocus);
viewport.addEventListener("pointerleave", handleBlur);
viewport.addEventListener("focusin", handleFocus);
viewport.addEventListener("focusout", handleBlur);
return () => {
viewport.removeEventListener("pointermove", handleFocus);
viewport.removeEventListener("pointerleave", handleBlur);
viewport.removeEventListener("focusin", handleFocus);
viewport.removeEventListener("focusout", handleBlur);
};
}
}, []);
return (
<ToastContext.Provider
value={React.useMemo(
() =>
Object.assign(handleDispatchDefault, {
success: handleDispatchSuccess,
error: handleDispatchError
}),
[handleDispatchDefault, handleDispatchSuccess, handleDispatchError]
)}
>
<ToastContextImpl.Provider
value={React.useMemo<ToastContextImplType>(
() => ({
toastElementsMapRef,
sortToasts
}),
[sortToasts]
)}
>
<ToastPrimitive.Provider {...props}>
{children}
{Array.from(toasts).map(([key, toast]) => (
<Toast
key={key}
id={key}
toast={toast}
onOpenChange={(open) => {
if (!open) {
toastElementsMapRef.current.delete(key);
sortToasts();
if (!open) {
setTimeout(() => {
handleRemoveToast(key);
}, ANIMATION_OUT_DURATION);
}
}
}}
/>
))}
<ToastPrimitive.Viewport
ref={viewportRef}
className="ToastViewport"
/>
</ToastPrimitive.Provider>
</ToastContextImpl.Provider>
</ToastContext.Provider>
);
};
export const useToast = () => {
const context = React.useContext(ToastContext);
if (context) return context;
throw new Error("useToast must be used within Toasts");
};
export const useToastContext = () => {
const context = React.useContext(ToastContextImpl);
if (context) return context;
throw new Error("useToast must be used within Toasts");
};
interface ToastProps {
id: string
toast: ToastType
onOpenChange: (open: boolean) => void
}
const Toast = (props: ToastProps) => {
const { onOpenChange, toast, id, ...toastProps } = props;
const ref = React.useRef<HTMLLIElement | null>(null); // todo: refactor to useref
const context = useToastContext();
const { sortToasts, toastElementsMapRef } = context;
const toastElementsMap = toastElementsMapRef?.current;
React.useLayoutEffect(() => {
if (ref.current) {
toastElementsMap?.set(id, ref.current);
sortToasts();
}
}, [id, sortToasts, toastElementsMap]);
return (
<ToastPrimitive.Root
{...toastProps}
ref={ref}
type={toast.type}
duration={toast.duration}
className="ToastRoot"
onOpenChange={onOpenChange}
>
<div className="ToastInner" data-status={toast.status}>
<ToastStatusIcon status={toast.status} />
<ToastPrimitive.Title className="ToastTitle">
<p>{toast.title}</p>
</ToastPrimitive.Title>
<ToastPrimitive.Description className="ToastDescription">
{toast.description}
</ToastPrimitive.Description>
<ToastPrimitive.Close aria-label="Close" className="ToastClose">
<Cross2Icon />
</ToastPrimitive.Close>
</div>
</ToastPrimitive.Root>
);
};
const ToastStatusIcon = ({ status }: {status: string}) => {
return status !== "default" ? (
<div style={{ gridArea: "icon", alignSelf: "start" }}>
{status === "success" && <div aria-hidden className="checkmark" />}
{status === "error" && <div aria-hidden className="error" />}
</div>
) : null;
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment