Created
December 19, 2024 13:26
-
-
Save lior-amsalem/cfe9161ffb7e5e0f6e4ac1fb0dd8f275 to your computer and use it in GitHub Desktop.
written in typescript and reactjs and typescript heres a toast
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
.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; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** ------------------------------------------------------- ** | |
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