radix-ui / primitives

Radix Primitives is an open-source UI component library for building high-quality, accessible design systems and web apps. Maintained by @workos.
https://radix-ui.com/primitives
MIT License
14.93k stars 729 forks source link

[Docs: Toast] Zero guidance on how to have a app-wide portable toast system which I can call from anywhere to toast any message #2804

Open ADTC opened 3 months ago

ADTC commented 3 months ago

Documentation

Radix Toast documentation shows very weirdly how to build a single toast surrounding a button which opens it.

That's it. Nothing more. How I'm supposed to build an application-wide toasting system from this, I don't know.

The docs look extremely lacking in clarity, and I'm surprised no one has raised this concern yet.

Relevant Radix Component(s)

The Radix doc shows how to build a Toaster from its parts, that envelopes a button which changes a state variable, which then does the toasting. It seems like its purpose is to just generate that toast on that button. So, it's not portable. What if I want to open a toast from an error state after sending a request to an API? I don't know how to derive that from this documentation.

You could say, have a button that calls the API, and wrap this button with <Toast.Provider> but how does that make sense? I have to wrap every button that needs a toast with it? How about toasting for events that are not triggered by buttons? Like a WebRTC push mesage?

What's even weirder is that in order to create multiple toasts, I have to create an array of Toast components? 🤯

Examples from other doc sites

Please compare to these where it shows how to toast from anywhere, in any JavaScript. You plugin a Toaster then you just toast. The example is a button click, but I can very easily derive how to toast from an error state after sending an API request. Multiple toasts? Just call toast again and again. Avoid duplicates? Just add ID to your toasts. Want duplicates? Don't add ID to your toasts.

Is this simplicity even possible to replicate in Radix Toast? Or am I just too dumb? 😂

aryankarim commented 1 month ago

What's even weirder is that in order to create multiple toasts, I have to create an array of Toast components? 🤯

It's funny that the only thing useful about this toaster is its styling.

Anyhow, here is how I solved it with jotai and tailwind. This ain't optimized but at least has more functionality than theirs.

Toast.tsx

import { Icon } from "@iconify-icon/react";
import * as Toast from "@radix-ui/react-toast";
import { useAtom } from "jotai";
import { useEffect, useMemo, useRef } from "react";

const Toaster = () => {
  const [{ list }] = useAtom(toastAtom);

  return (
    <Toast.Provider swipeDirection="right">
      {list.map((props) => (
        <SingleToast {...props} key={props.id} />
      ))}
      <Toast.Viewport className="fixed bottom-0 right-0 flex flex-col p-8 gap-3 w-96 max-w-screen m-0 list-none !z-50 outline-none " />
    </Toast.Provider>
  );
};

const modes: { [type: string]: { textColor: string; borderColor: string } } = {
  positive: { textColor: "text-positive", borderColor: "border-positive" },
  negative: { textColor: "text-negative", borderColor: "border-negative" },
  info: { textColor: "text-info", borderColor: "border-info" },
};

const SingleToast = ({
  id,
  open = true,
  title = "",
  subtitle = "",
  mode = "info",
  timer = 3000,
  infinite = false,
}: {
  id?: number;
  open?: boolean;
  title: string;
  subtitle: string;
  mode?: string;
  timer?: number;
  infinite?: boolean;
}) => {
  const [_, setToast] = useAtom(toastAtom);
  const timerRef = useRef<NodeJS.Timeout | undefined>();

  const removeToast = () => {
    setToast((state) => {
      state.list = state.list.filter((toast) => toast.id != id);
    });
    timerRef.current && clearTimeout(timerRef.current);
  };

  const setTimer = () => {
    timerRef.current = setTimeout(() => {
      removeToast();
      clearTimeout(timerRef.current);
    }, timer + 1000);
  };

  useEffect(() => {
    !infinite && setTimer();
  }, []);

  return (
    <Toast.Root
      className={`${modes[mode].borderColor} border border-accent/50 bg-light dark:bg-dark rounded-md shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,_hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px] p-[15px] grid [grid-template-areas:_'title_action'_'description_action'] grid-cols-[auto_max-content] gap-x-[15px] items-center `}
      duration={Infinity}
    >
      <Toast.Title className={`font-bold ${modes[mode].textColor}`}>{title}</Toast.Title>
      <Toast.Description className="text-base">{subtitle}</Toast.Description>
      <Toast.Action className="[grid-area:_action]" asChild altText="Dismiss">
        <Toast.Close
          aria-label="Close"
          className={`border rounded-full h-8 w-8 flex justify-center items-center ${modes[mode].borderColor}`}
          onClick={removeToast}
        >
          <Icon icon="ph:x-thin" className={`text-xl ${modes[mode].textColor}`} />
        </Toast.Close>
      </Toast.Action>
    </Toast.Root>
  );
};

export default Toaster;

useToast.ts

export const useToast = () => {
  const [_, setToast] = useAtom(toastAtom);

  return ({ timer = 3000, ...toast }: Toast) => {
    const id = Date.now() + Math.random();

    setToast((state) => {
      state.list = [...state.list, { id, timer, ...toast }];
    });
  };
};

store.ts

import { atomWithImmer } from "jotai-immer";

export const toastAtom = atomWithImmer<{ list: Array<Toast> }>({ list: [] });

types.d.ts

interface Toast {
  id?: number;
  open?: boolean;
  title: string;
  subtitle: string;
  timer?: number;
  infinite?: boolean;
  mode?: "info" | "positive" | "negative";
}

Usage

App.tsx

function App({ children }: PropsWithChildren) {
  const toast = useToast();

  useEffect(() => {
    toast({ title: "Failed", subtitle: "Something went wrong!", mode: "negative" });
    toast({ title: "Success", subtitle: "Hurray!", mode: "positive", timer: 8000 });
    toast({ title: "Info", subtitle: "Cool info!", timer: 12000 });
    toast({ title: "Infinite", subtitle: "I'll always be here!", infinite: true });
  }, []);
  return (
    <RadixTheme>
      {children}
      <Toaster />
    </RadixTheme>
  );
}

export default App;