From 635c8670c450b2a0596a79de633c6da53465dc66 Mon Sep 17 00:00:00 2001
From: Cahllagerfeld <43843195+Cahllagerfeld@users.noreply.github.com>
Date: Wed, 10 Apr 2024 09:12:17 +0000
Subject: [PATCH 1/2] feat: add toast component
---
.eslintrc.json | 52 ++++---
package.json | 1 +
pnpm-lock.yaml | 34 +++++
src/components/Sidebar/Sidebar.tsx | 4 +-
src/components/Toast/Toast.stories.tsx | 73 ++++++++++
src/components/Toast/Toast.tsx | 182 ++++++++++++++++++++++++
src/components/Toast/Toaster.tsx | 36 +++++
src/components/Toast/index.tsx | 3 +
src/components/Toast/use-toast.tsx | 188 +++++++++++++++++++++++++
src/components/index.ts | 1 +
10 files changed, 545 insertions(+), 29 deletions(-)
create mode 100644 src/components/Toast/Toast.stories.tsx
create mode 100644 src/components/Toast/Toast.tsx
create mode 100644 src/components/Toast/Toaster.tsx
create mode 100644 src/components/Toast/index.tsx
create mode 100644 src/components/Toast/use-toast.tsx
diff --git a/.eslintrc.json b/.eslintrc.json
index b3dfd1a..90692cd 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -1,29 +1,27 @@
{
- "env": {
- "browser": true,
- "es2021": true
- },
- "extends": [
- "eslint:recommended",
- "plugin:@typescript-eslint/recommended",
- "plugin:react/recommended",
- "plugin:storybook/recommended"
- ],
- "parser": "@typescript-eslint/parser",
- "parserOptions": {
- "ecmaVersion": "latest",
- "sourceType": "module"
- },
- "plugins": [
- "@typescript-eslint",
- "react"
- ],
- "settings": {
- "react": {
- "version": "detect"
- }
- },
- "rules": {
- "react/prop-types": "off"
- }
+ "env": {
+ "browser": true,
+ "es2021": true
+ },
+ "extends": [
+ "eslint:recommended",
+ "plugin:@typescript-eslint/recommended",
+ "plugin:react/recommended",
+ "plugin:storybook/recommended"
+ ],
+ "parser": "@typescript-eslint/parser",
+ "parserOptions": {
+ "ecmaVersion": "latest",
+ "sourceType": "module"
+ },
+ "plugins": ["@typescript-eslint", "react"],
+ "settings": {
+ "react": {
+ "version": "detect"
+ }
+ },
+ "rules": {
+ "react/prop-types": "off",
+ "no-mixed-spaces-and-tabs": "off"
+ }
}
diff --git a/package.json b/package.json
index e01dc79..5198cf2 100644
--- a/package.json
+++ b/package.json
@@ -91,6 +91,7 @@
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
+ "@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-table": "^8.15.3",
"class-variance-authority": "^0.7.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 249f9cd..ee6b8f5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -26,6 +26,9 @@ dependencies:
'@radix-ui/react-tabs':
specifier: ^1.0.4
version: 1.0.4(@types/react@18.2.28)(react-dom@18.2.0)(react@18.2.0)
+ '@radix-ui/react-toast':
+ specifier: ^1.1.5
+ version: 1.1.5(@types/react@18.2.28)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-tooltip':
specifier: ^1.0.7
version: 1.0.7(@types/react@18.2.28)(react-dom@18.2.0)(react@18.2.0)
@@ -2690,6 +2693,37 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
+ /@radix-ui/react-toast@1.1.5(@types/react@18.2.28)(react-dom@18.2.0)(react@18.2.0):
+ resolution: {integrity: sha512-fRLn227WHIBRSzuRzGJ8W+5YALxofH23y0MlPLddaIpLpCDqdE0NZlS2NRQDRiptfxDeeCjgFIpexB1/zkxDlw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0
+ react-dom: ^16.8 || ^17.0 || ^18.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+ dependencies:
+ '@babel/runtime': 7.24.0
+ '@radix-ui/primitive': 1.0.1
+ '@radix-ui/react-collection': 1.0.3(@types/react@18.2.28)(react-dom@18.2.0)(react@18.2.0)
+ '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.28)(react@18.2.0)
+ '@radix-ui/react-context': 1.0.1(@types/react@18.2.28)(react@18.2.0)
+ '@radix-ui/react-dismissable-layer': 1.0.5(@types/react@18.2.28)(react-dom@18.2.0)(react@18.2.0)
+ '@radix-ui/react-portal': 1.0.4(@types/react@18.2.28)(react-dom@18.2.0)(react@18.2.0)
+ '@radix-ui/react-presence': 1.0.1(@types/react@18.2.28)(react-dom@18.2.0)(react@18.2.0)
+ '@radix-ui/react-primitive': 1.0.3(@types/react@18.2.28)(react-dom@18.2.0)(react@18.2.0)
+ '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.28)(react@18.2.0)
+ '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.28)(react@18.2.0)
+ '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.28)(react@18.2.0)
+ '@radix-ui/react-visually-hidden': 1.0.3(@types/react@18.2.28)(react-dom@18.2.0)(react@18.2.0)
+ '@types/react': 18.2.28
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+ dev: false
+
/@radix-ui/react-tooltip@1.0.7(@types/react@18.2.28)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-lPh5iKNFVQ/jav/j6ZrWq3blfDJ0OH9R6FlNUHPMqdLuQ9vwDgFsRxvl8b7Asuy5c8xmoojHUxKHQSOAvMHxyw==}
peerDependencies:
diff --git a/src/components/Sidebar/Sidebar.tsx b/src/components/Sidebar/Sidebar.tsx
index 4f7f381..158e7d5 100644
--- a/src/components/Sidebar/Sidebar.tsx
+++ b/src/components/Sidebar/Sidebar.tsx
@@ -149,8 +149,8 @@ export function SidebarItemContent({
? "stroke-primary-400"
: "stroke-theme-text-tertiary"
: isActive
- ? "fill-primary-400"
- : "fill-theme-text-tertiary"
+ ? "fill-primary-400"
+ : "fill-theme-text-tertiary"
} `
);
return (
diff --git a/src/components/Toast/Toast.stories.tsx b/src/components/Toast/Toast.stories.tsx
new file mode 100644
index 0000000..7d42872
--- /dev/null
+++ b/src/components/Toast/Toast.stories.tsx
@@ -0,0 +1,73 @@
+import { Meta } from "@storybook/react";
+import React from "react";
+import { Toaster, Toast, toast } from "./index";
+import { StoryObj } from "@storybook/react";
+import { Button } from "../Button";
+
+const meta = {
+ title: "Elements/Toast",
+ component: Toast,
+ argTypes: {
+ emphasis: {
+ description: "Emphasis of the toast",
+ control: "select",
+ defaultValue: "subtle",
+ options: ["subtle", "bold"]
+ },
+ status: {
+ description: "Status of the toast",
+ control: "select",
+ defaultValue: "default",
+ options: ["default", "error", "success", "warning"]
+ },
+ rounded: {
+ control: "boolean",
+ defaultValue: true
+ }
+ },
+ parameters: {
+ layout: "centered"
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ )
+ ],
+
+ tags: ["autodocs"]
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const DefaultVariant: Story = {
+ name: "Toast",
+ argTypes: {
+ // @ts-expect-error for some reason a wrong type is picked up there
+ emphasis: "subtle",
+ // @ts-expect-error for some reason a wrong type is picked up there
+ status: "error",
+ // @ts-expect-error for some reason a wrong type is picked up there
+ rounded: "true"
+ },
+ render: ({ emphasis, status, rounded }) => (
+
+ {
+ toast({
+ status,
+ emphasis,
+ description: "This is a toast message",
+ rounded
+ });
+ }}
+ >
+ Add Toast
+
+
+
+ )
+};
diff --git a/src/components/Toast/Toast.tsx b/src/components/Toast/Toast.tsx
new file mode 100644
index 0000000..ca2939c
--- /dev/null
+++ b/src/components/Toast/Toast.tsx
@@ -0,0 +1,182 @@
+import * as ToastPrimitives from "@radix-ui/react-toast";
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
+import { cn } from "../../utilities";
+
+const ToastProvider = ToastPrimitives.Provider;
+
+const ToastViewport = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
+
+export const toastVariants = cva(
+ "group pointer-events-auto pl-4 pr-6 py-3 relative flex w-full items-center justify-between overflow-hidden border transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
+ {
+ variants: {
+ rounded: {
+ true: "rounded-md",
+ false: ""
+ },
+ status: {
+ default: "",
+ error: "",
+ success: "",
+ warning: ""
+ },
+ emphasis: {
+ subtle: "border",
+ bold: ""
+ }
+ },
+ compoundVariants: [
+ {
+ emphasis: "bold",
+ status: "default",
+ className: "white-close bg-theme-surface-strong text-theme-text-negative"
+ },
+ {
+ emphasis: "subtle",
+ status: "default",
+ class: "bg-primary-25 border-primary-400 text-theme-text-primary"
+ },
+ {
+ emphasis: "bold",
+ status: "error",
+ className: "white-close bg-error-600 text-theme-text-negative"
+ },
+ {
+ emphasis: "subtle",
+ status: "error",
+ className: "bg-error-50 border-error-300 text-error-900"
+ },
+ {
+ emphasis: "bold",
+ status: "success",
+ className: "white-close bg-success-700 text-theme-text-negative"
+ },
+ {
+ emphasis: "subtle",
+ status: "success",
+ className: "bg-success-50 border-success-300 text-success-900"
+ },
+ {
+ emphasis: "bold",
+ status: "warning",
+ className: "bg-warning-400"
+ },
+ {
+ emphasis: "subtle",
+ status: "warning",
+ className: "bg-warning-50 border-warning-300 text-warning-900"
+ }
+ ],
+ defaultVariants: {
+ status: "default"
+ }
+ }
+);
+
+const Toast = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & VariantProps
+>(({ className, status, rounded, emphasis, ...props }, ref) => {
+ return (
+
+ );
+});
+Toast.displayName = ToastPrimitives.Root.displayName;
+
+const ToastAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+ToastAction.displayName = ToastPrimitives.Action.displayName;
+
+const ToastClose = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+
+));
+ToastClose.displayName = ToastPrimitives.Close.displayName;
+
+const ToastTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+ToastTitle.displayName = ToastPrimitives.Title.displayName;
+
+const ToastDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+ToastDescription.displayName = ToastPrimitives.Description.displayName;
+
+type ToastProps = React.ComponentPropsWithoutRef;
+
+type ToastActionElement = React.ReactElement;
+
+export {
+ Toast,
+ ToastAction,
+ ToastClose,
+ ToastDescription,
+ ToastProvider,
+ ToastTitle,
+ ToastViewport,
+ type ToastActionElement,
+ type ToastProps
+};
diff --git a/src/components/Toast/Toaster.tsx b/src/components/Toast/Toaster.tsx
new file mode 100644
index 0000000..70fd91e
--- /dev/null
+++ b/src/components/Toast/Toaster.tsx
@@ -0,0 +1,36 @@
+import React from "react";
+import {
+ Toast,
+ ToastClose,
+ ToastDescription,
+ ToastProvider,
+ ToastTitle,
+ ToastViewport
+} from "./Toast";
+import { useToast } from "./use-toast";
+
+export function Toaster() {
+ const { toasts } = useToast();
+
+ return (
+
+ {toasts.map(function ({ id, title, description, action, icon, ...props }) {
+ return (
+
+
+
+ {icon && icon}
+ {title && {title} }
+ {description && {description} }
+
+
+ {action &&
{action}
}
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/Toast/index.tsx b/src/components/Toast/index.tsx
new file mode 100644
index 0000000..d21acc2
--- /dev/null
+++ b/src/components/Toast/index.tsx
@@ -0,0 +1,3 @@
+export * from "./Toast";
+export * from "./Toaster";
+export * from "./use-toast";
diff --git a/src/components/Toast/use-toast.tsx b/src/components/Toast/use-toast.tsx
new file mode 100644
index 0000000..6f876e8
--- /dev/null
+++ b/src/components/Toast/use-toast.tsx
@@ -0,0 +1,188 @@
+// Inspired by react-hot-toast library
+import * as React from "react";
+
+import type { ToastActionElement, ToastProps } from "./Toast";
+
+const TOAST_LIMIT = 2;
+const TOAST_REMOVE_DELAY = 1000000;
+
+type ToasterToast = ToastProps & {
+ id: string;
+ title?: React.ReactNode;
+ description?: React.ReactNode;
+ action?: ToastActionElement;
+ icon?: React.ReactNode;
+};
+
+const actionTypes = {
+ ADD_TOAST: "ADD_TOAST",
+ UPDATE_TOAST: "UPDATE_TOAST",
+ DISMISS_TOAST: "DISMISS_TOAST",
+ REMOVE_TOAST: "REMOVE_TOAST"
+} as const;
+
+let count = 0;
+
+function genId() {
+ count = (count + 1) % Number.MAX_VALUE;
+ return count.toString();
+}
+
+type ActionType = typeof actionTypes;
+
+type Action =
+ | {
+ type: ActionType["ADD_TOAST"];
+ toast: ToasterToast;
+ }
+ | {
+ type: ActionType["UPDATE_TOAST"];
+ toast: Partial;
+ }
+ | {
+ type: ActionType["DISMISS_TOAST"];
+ toastId?: ToasterToast["id"];
+ }
+ | {
+ type: ActionType["REMOVE_TOAST"];
+ toastId?: ToasterToast["id"];
+ };
+
+interface State {
+ toasts: ToasterToast[];
+}
+
+const toastTimeouts = new Map>();
+
+const addToRemoveQueue = (toastId: string) => {
+ if (toastTimeouts.has(toastId)) {
+ return;
+ }
+
+ const timeout = setTimeout(() => {
+ toastTimeouts.delete(toastId);
+ dispatch({
+ type: "REMOVE_TOAST",
+ toastId: toastId
+ });
+ }, TOAST_REMOVE_DELAY);
+
+ toastTimeouts.set(toastId, timeout);
+};
+
+export const reducer = (state: State, action: Action): State => {
+ switch (action.type) {
+ case "ADD_TOAST":
+ return {
+ ...state,
+ toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT)
+ };
+
+ case "UPDATE_TOAST":
+ return {
+ ...state,
+ toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t))
+ };
+
+ case "DISMISS_TOAST": {
+ const { toastId } = action;
+
+ // ! Side effects ! - This could be extracted into a dismissToast() action,
+ // but I'll keep it here for simplicity
+ if (toastId) {
+ addToRemoveQueue(toastId);
+ } else {
+ state.toasts.forEach((toast) => {
+ addToRemoveQueue(toast.id);
+ });
+ }
+
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === toastId || toastId === undefined
+ ? {
+ ...t,
+ open: false
+ }
+ : t
+ )
+ };
+ }
+ case "REMOVE_TOAST":
+ if (action.toastId === undefined) {
+ return {
+ ...state,
+ toasts: []
+ };
+ }
+ return {
+ ...state,
+ toasts: state.toasts.filter((t) => t.id !== action.toastId)
+ };
+ }
+};
+
+const listeners: Array<(state: State) => void> = [];
+
+let memoryState: State = { toasts: [] };
+
+function dispatch(action: Action) {
+ memoryState = reducer(memoryState, action);
+ listeners.forEach((listener) => {
+ listener(memoryState);
+ });
+}
+
+type Toast = Omit;
+
+function toast({ ...props }: Toast) {
+ const id = genId();
+
+ const update = (props: ToasterToast) =>
+ dispatch({
+ type: "UPDATE_TOAST",
+ toast: { ...props, id }
+ });
+ const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
+
+ dispatch({
+ type: "ADD_TOAST",
+ toast: {
+ ...props,
+ id,
+ open: true,
+ onOpenChange: (open) => {
+ if (!open) dismiss();
+ }
+ }
+ });
+
+ return {
+ id: id,
+ dismiss,
+ update
+ };
+}
+
+function useToast() {
+ const [state, setState] = React.useState(memoryState);
+
+ React.useEffect(() => {
+ listeners.push(setState);
+ return () => {
+ const index = listeners.indexOf(setState);
+ if (index > -1) {
+ listeners.splice(index, 1);
+ }
+ };
+ }, [state]);
+
+ return {
+ ...state,
+ toast,
+ dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId })
+ };
+}
+
+export { toast, useToast };
diff --git a/src/components/index.ts b/src/components/index.ts
index dd6fa63..455ee43 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -14,3 +14,4 @@ export * from "./Tabs";
export * from "./Collapsible";
export * from "./Tooltip";
export * from "./Checkbox";
+export * from "./Toast";
From 3a437392225a53b7272d6ef50ab4af2a6f5dc865 Mon Sep 17 00:00:00 2001
From: Cahllagerfeld <43843195+Cahllagerfeld@users.noreply.github.com>
Date: Wed, 10 Apr 2024 09:12:40 +0000
Subject: [PATCH 2/2] chore: add changeset
---
.changeset/calm-ties-brush.md | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 .changeset/calm-ties-brush.md
diff --git a/.changeset/calm-ties-brush.md b/.changeset/calm-ties-brush.md
new file mode 100644
index 0000000..995e4a1
--- /dev/null
+++ b/.changeset/calm-ties-brush.md
@@ -0,0 +1,5 @@
+---
+"@zenml-io/react-component-library": minor
+---
+
+add toast