diff --git a/src/renderer/src/components/toast/toast.css.ts b/src/renderer/src/components/toast/toast.css.ts new file mode 100644 index 00000000..9035c8e8 --- /dev/null +++ b/src/renderer/src/components/toast/toast.css.ts @@ -0,0 +1,81 @@ +import { keyframes, style } from "@vanilla-extract/css"; + +import { SPACING_UNIT, vars } from "../../theme.css"; +import { recipe } from "@vanilla-extract/recipes"; + +const TOAST_HEIGHT = 60; + +export const slideIn = keyframes({ + "0%": { transform: `translateY(${TOAST_HEIGHT + SPACING_UNIT * 2}px)` }, + "100%": { transform: "translateY(0)" }, +}); + +export const slideOut = keyframes({ + "0%": { transform: `translateY(0)` }, + "100%": { transform: `translateY(${TOAST_HEIGHT + SPACING_UNIT * 2}px)` }, +}); + +export const toast = recipe({ + base: { + animationDuration: "0.2s", + animationTimingFunction: "ease-in-out", + height: TOAST_HEIGHT, + position: "fixed", + backgroundColor: vars.color.background, + borderRadius: "4px", + border: `solid 1px ${vars.color.border}`, + left: "50%", + /* Bottom panel height + spacing */ + bottom: `${26 + SPACING_UNIT * 2}px`, + overflow: "hidden", + display: "flex", + flexDirection: "column", + justifyContent: "space-between", + }, + variants: { + closing: { + true: { + animationName: slideOut, + transform: `translateY(${TOAST_HEIGHT + SPACING_UNIT * 2}px)`, + }, + false: { + animationName: slideIn, + transform: `translateY(0)`, + }, + }, + }, +}); + +export const toastContent = style({ + display: "flex", + position: "relative", + gap: `${SPACING_UNIT}px`, + padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 5}px`, + paddingLeft: `${SPACING_UNIT * 2}px`, + justifyContent: "center", + alignItems: "center", +}); + +export const progress = style({ + width: "100%", + height: "5px", + "::-webkit-progress-bar": { + backgroundColor: vars.color.darkBackground, + }, + "::-webkit-progress-value": { + backgroundColor: "#1c9749", + }, +}); + +export const closeButton = style({ + position: "absolute", + right: `${SPACING_UNIT}px`, + color: vars.color.bodyText, + cursor: "pointer", + padding: "0", + margin: "0", +}); + +export const successIcon = style({ + color: "#1c9749", +}); diff --git a/src/renderer/src/components/toast/toast.tsx b/src/renderer/src/components/toast/toast.tsx new file mode 100644 index 00000000..8ae9f934 --- /dev/null +++ b/src/renderer/src/components/toast/toast.tsx @@ -0,0 +1,96 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + CheckCircleFillIcon, + CheckCircleIcon, + XCircleIcon, + XIcon, +} from "@primer/octicons-react"; + +import * as styles from "./toast.css"; + +export interface ToastProps { + visible: boolean; + message: string; + type: "success" | "error"; + onClose: () => void; +} + +const INITIAL_PROGRESS = 100; + +export function Toast({ visible, message, type, onClose }: ToastProps) { + const [isClosing, setIsClosing] = useState(false); + const [progress, setProgress] = useState(INITIAL_PROGRESS); + + const closingAnimation = useRef(-1); + const progressAnimation = useRef(-1); + + const startAnimateClosing = useCallback(() => { + setIsClosing(true); + const zero = performance.now(); + + closingAnimation.current = requestAnimationFrame( + function animateClosing(time) { + if (time - zero <= 200) { + closingAnimation.current = requestAnimationFrame(animateClosing); + } else { + onClose(); + } + } + ); + }, [onClose]); + + useEffect(() => { + if (visible) { + const zero = performance.now(); + + progressAnimation.current = requestAnimationFrame( + function animateProgress(time) { + const elapsed = time - zero; + + const progress = Math.min(elapsed / 2500, 1); + const currentValue = + INITIAL_PROGRESS + (0 - INITIAL_PROGRESS) * progress; + + setProgress(currentValue); + + if (progress < 1) { + progressAnimation.current = requestAnimationFrame(animateProgress); + } else { + cancelAnimationFrame(progressAnimation.current); + startAnimateClosing(); + } + } + ); + + return () => { + setProgress(INITIAL_PROGRESS); + cancelAnimationFrame(closingAnimation.current); + cancelAnimationFrame(progressAnimation.current); + setIsClosing(false); + }; + } + + return () => {}; + }, [startAnimateClosing, visible]); + + if (!visible) return null; + + return ( +
+
+ + {message} + + +
+ + +
+ ); +}