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 }) => ( +
+ + +
+ ) +}; 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