Skip to content

Commit

Permalink
feat: new toasts
Browse files Browse the repository at this point in the history
  • Loading branch information
sylv committed Jun 18, 2024
1 parent db64ab0 commit 4d96fe3
Show file tree
Hide file tree
Showing 20 changed files with 460 additions and 230 deletions.
5 changes: 5 additions & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,10 @@
"vite": "^5.2.11",
"vite-plugin-graphql-codegen": "^3.3.6",
"yup": "^1.4.0"
},
"dependencies": {
"framer-motion": "^11.2.10",
"immer": "^10.1.1",
"zustand": "^4.5.2"
}
}
25 changes: 12 additions & 13 deletions packages/web/src/app.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import type { FC } from 'react';
import React, { Fragment } from 'react';
import { Header } from './components/header/header';
import { Title } from './components/title';
import './styles/globals.css';
import { ToastProvider } from './components/toast';
import { Helmet } from 'react-helmet-async';
import type { FC } from "react";
import { Fragment } from "react";
import { Header } from "./components/header/header";
import { Title } from "./components/title";
import "./styles/globals.css";
import { Helmet } from "react-helmet-async";
import { ToastProvider } from "./components/toast/toast-provider";

interface AppProps {
children: React.ReactNode;
}

declare module 'react' {
export type SVGAttributes<T extends EventTarget> = import('preact').JSX.SVGAttributes<T>;
declare module "react" {
export type SVGAttributes<T extends EventTarget> = import("preact").JSX.SVGAttributes<T>;
}

export const App: FC<AppProps> = ({ children }) => (
Expand All @@ -21,9 +21,8 @@ export const App: FC<AppProps> = ({ children }) => (
<meta property="og:site_name" content="micro" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</Helmet>
<ToastProvider>
<Header />
<div className="py-4 md:py-16">{children}</div>
</ToastProvider>
<Header />
<div className="py-4 md:py-16">{children}</div>
<ToastProvider />
</Fragment>
);
27 changes: 14 additions & 13 deletions packages/web/src/components/button.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,36 @@
import clsx from 'clsx';
import type { FC, HTMLAttributes } from 'react';
import { forwardRef } from 'react';
import { Spinner } from './spinner';
import clsx from "clsx";
import type { FC, HTMLAttributes } from "react";
import { forwardRef } from "react";
import { Spinner } from "./spinner";

type ButtonBaseProps = Omit<
HTMLAttributes<HTMLButtonElement | HTMLAnchorElement>,
'prefix' | 'style' | 'as' | 'loading'
"prefix" | "style" | "as" | "loading"
>;

export interface ButtonProps extends ButtonBaseProps {
href?: string;
disabled?: boolean;
style?: ButtonStyle;
loading?: boolean;
type?: 'submit' | 'reset' | 'button';
as?: FC | 'button' | 'a';
type?: "submit" | "reset" | "button";
as?: FC | "button" | "a";
}

export enum ButtonStyle {
Primary = 'bg-purple-500 hover:bg-purple-400',
Secondary = 'bg-dark-600 hover:bg-dark-900',
Disabled = 'bg-dark-300 hover:bg-dark-400 cursor-not-allowed',
Primary = "bg-purple-500 hover:bg-purple-400",
Secondary = "bg-dark-600 hover:bg-dark-900",
Disabled = "bg-dark-300 hover:bg-dark-400 cursor-not-allowed",
Transparent = "bg-transparent hover:bg-dark-900",
}

export const BASE_BUTTON_CLASSES =
'flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium transition rounded truncate max-h-[2.65em]';
"flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium transition rounded truncate max-h-[2.65em]";

export const Button = forwardRef<any, ButtonProps>(
(
{
as: As = 'button',
as: As = "button",
disabled,
className,
type,
Expand All @@ -54,7 +55,7 @@ export const Button = forwardRef<any, ButtonProps>(
disabled={disabled}
onClick={onClickWrap}
onKeyDown={onKeyDownWrap}
style={{ height: '2.5rem' }}
style={{ height: "2.5rem" }}
{...rest}
ref={ref}
>
Expand Down
13 changes: 7 additions & 6 deletions packages/web/src/components/header/header.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import clsx from "clsx";
import { Fragment, memo, useRef, useState } from "react";
import { FiCrop } from "react-icons/fi";
import { graphql } from "../../@generated/gql";
import { useAsync } from "../../hooks/useAsync";
import { useConfig } from "../../hooks/useConfig";
import { useErrorMutation } from "../../hooks/useErrorMutation";
import { useOnClickOutside } from "../../hooks/useOnClickOutside";
import { usePaths } from "../../hooks/usePaths";
import { useUser } from "../../hooks/useUser";
import { Button, ButtonStyle } from "../button";
import { Container } from "../container";
import { Input } from "../input/input";
import { Link } from "../link";
import { useToasts } from "../toast";
import { createToast } from "../toast/store";
import { ToastStyle } from "../toast/toast";
import { HeaderUser } from "./header-user";
import { graphql } from "../../@generated/gql";
import { useErrorMutation } from "../../hooks/useErrorMutation";

const ResendVerificationEmail = graphql(`
mutation ResendVerificationEmail($data: ResendVerificationEmailDto) {
Expand All @@ -28,7 +29,6 @@ export const Header = memo(() => {
const [showEmailInput, setShowEmailInput] = useState(false);
const emailInputRef = useRef<HTMLDivElement>(null);
const [email, setEmail] = useState("");
const createToast = useToasts();
const [resent, setResent] = useState(false);
const classes = clsx(
"relative z-20 flex items-center justify-between h-16 my-auto transition",
Expand Down Expand Up @@ -59,8 +59,9 @@ export const Header = memo(() => {
} catch (error: any) {
if (error.message.includes("You can only") || error.message.includes("You have already")) {
createToast({
text: "You have already requested a verification email. Please check your inbox, or try resend in 5 minutes.",
error: true,
style: ToastStyle.Error,
message:
"You have already requested a verification email. Please check your inbox, or try resend in 5 minutes.",
});
return;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { Language } from 'prism-react-renderer';
import { memo } from 'react';
import { FiChevronDown } from 'react-icons/fi';
import languages from '../../data/languages.json';
import { useToasts } from '../toast';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import type { Language } from "prism-react-renderer";
import { memo } from "react";
import { FiChevronDown } from "react-icons/fi";
import languages from "../../data/languages.json";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { createToast } from "../toast/store";

export interface SyntaxHighlighterControlsProps {
onLanguageChange: (language: Language) => void;
Expand All @@ -13,10 +13,9 @@ export interface SyntaxHighlighterControlsProps {

export const SyntaxHighlighterControls = memo<SyntaxHighlighterControlsProps>(
({ language, content, onLanguageChange }) => {
const createToast = useToasts();
const copyContent = () => {
navigator.clipboard.writeText(content);
createToast({ text: 'Copied file content to clipboard.' });
createToast({ message: "Copied file content to clipboard." });
};

return (
Expand All @@ -25,7 +24,7 @@ export const SyntaxHighlighterControls = memo<SyntaxHighlighterControlsProps>(
<DropdownMenu.Trigger className="text-xs flex items-center gap-1 text-gray-500 hover:text-white transition pt-2 outline-none">
{language} <FiChevronDown className="h-4 w-4" />
</DropdownMenu.Trigger>
<DropdownMenu.Content className="absolute top-full mt-3 bg-gray-900 text-sm max-h-44 overflow-y-scroll text-xs right-0 rounded">
<DropdownMenu.Content className="absolute top-full mt-3 bg-gray-900 max-h-44 overflow-y-scroll text-xs right-0 rounded">
{languages.map((language) => (
<DropdownMenu.Item
key={language.name}
Expand Down
5 changes: 0 additions & 5 deletions packages/web/src/components/toast/context.ts

This file was deleted.

3 changes: 0 additions & 3 deletions packages/web/src/components/toast/index.ts

This file was deleted.

104 changes: 104 additions & 0 deletions packages/web/src/components/toast/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { ReactNode } from "react";
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import { ToastStyle, type ToastProps } from "./toast";

const TRANSITION_INTERVAL = 150;
let currentToastId = 0;

export interface ToastOptions {
id: number;
message: ReactNode;
className?: string;
removing?: boolean;
style?: ToastStyle;
duration?: number | null;
onConfirm?: () => void;
onCancel?: () => void;
onClose?: () => void;
}

type State = {
items: ToastProps[];
};

const useToastStore = create<State>()(
immer((set) => ({
items: [],
})),
);

export const removeToast = (id: number) => {
let hasToast: boolean | undefined;
useToastStore.setState((draft) => {
const toast = draft.items.find((toast) => toast.id === id);
if (toast) {
if (toast.removing) return;
toast.removing = true;
hasToast = true;
}
});

if (hasToast) {
setTimeout(() => {
useToastStore.setState((draft) => {
draft.items = draft.items.filter((item) => item.id !== id);
});
}, TRANSITION_INTERVAL);
}
};

export const createToast = (options: Omit<ToastOptions, "id" | "removing">) => {
if (typeof window === "undefined") return;
const id = currentToastId++;
const hasButtons = !!(options.onConfirm || options.onCancel);
const timer: NodeJS.Timeout | undefined = undefined;
const onRemove = () => {
if (timer) clearTimeout(timer);
removeToast(id);
if (options.onClose) {
options.onClose();
}
};

const duration =
options.duration === null ? options.duration : options.duration || (hasButtons ? 10000 : 5000);
const expiresAt = duration === null ? null : Date.now() + duration;
const toast: ToastProps = {
...options,
id: id,
removing: false,
expiresAt: expiresAt,
createdAt: Date.now(),
onRemove,
};

useToastStore.setState((draft) => {
const existingToast = draft.items.find((toast) => toast.message === options.message);
if (existingToast) {
console.debug(
`Toast with message "${options.message}" already exists, refreshing time on existing toast.`,
);
existingToast.expiresAt = expiresAt;
existingToast.createdAt = Date.now();
return;
}

draft.items.push(toast);
});

return toast;
};

export const updateToast = (id: number, options: Partial<ToastProps>) => {
useToastStore.setState((draft) => {
const toast = draft.items.find((toast) => toast.id === id);
if (toast) {
Object.assign(toast, options);
}
});
};

export const useToasts = () => {
return useToastStore((store) => store.items);
};
71 changes: 16 additions & 55 deletions packages/web/src/components/toast/toast-provider.tsx
Original file line number Diff line number Diff line change
@@ -1,59 +1,20 @@
import { nanoid } from 'nanoid';
import type { FC, ReactNode } from 'react';
import { useCallback, useState } from 'preact/hooks';
import { Fragment } from 'preact';
import { ToastContext } from './context';
import type { ToastProps } from './toast';
import { TRANSITION_DURATION, Toast } from './toast';
import { memo, useState } from "react";
import { useToasts } from "./store";
import { Toast } from "./toast";

// spread operators on arrays are to fix this
// https://stackoverflow.com/questions/56266575/why-is-usestate-not-triggering-re-render

export const ToastProvider: FC<{ children: ReactNode }> = (props) => {
const [toasts, setToasts] = useState<(ToastProps & { id: string; timer: NodeJS.Timeout })[]>([]);
const createToast = useCallback(
(toast: ToastProps) => {
if (toasts.some((existing) => existing.text === toast.text)) {
// skip duplicate cards
return;
}

const timeout = toast.timeout ?? 5000;
const id = nanoid();
const timer = setTimeout(() => {
// set removing on toast starting the transition
setToasts((toasts) => {
for (const toast of toasts) {
if (toast.id !== id) continue;
toast.removing = true;
}

return [...toasts];
});

setTimeout(() => {
// remove toast once transition is complete
setToasts((toasts) => toasts.filter((toast) => toast.id !== id));
}, TRANSITION_DURATION);
}, timeout - TRANSITION_DURATION);

// create toast
setToasts((toasts) => {
const data = Object.assign(toast, { id, timer });
return [...toasts, data];
});
},
[toasts, setToasts],
);
export const ToastProvider = memo(() => {
const toasts = useToasts();
const [hovered, setHovered] = useState(false);

return (
<ToastContext.Provider value={createToast}>
<Fragment>{props.children}</Fragment>
<div className="fixed flex justify-end bottom-5 right-5 left-5">
{toasts.map((toast) => (
<Toast key={toast.id} removing={toast.removing} {...toast} />
))}
</div>
</ToastContext.Provider>
<div
className="fixed top-4 right-4 left-4 sm:left-auto sm:ml-4 min-w-[20em] space-y-2 z-50"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{toasts.map((toast) => (
<Toast key={toast.id} {...toast} paused={hovered} />
))}
</div>
);
};
});
Loading

0 comments on commit 4d96fe3

Please sign in to comment.