diff --git a/package.json b/package.json index 7c6c1a33..461a6d9d 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "react-aria-components": "1.16.0", "react-resizable-panels": "2.1.7", "react-stately": "3.37.0", + "react-toastify": "^11.1.0", "react-virtualized": "9.22.6", "react-pdf": "10.1.0" }, diff --git a/src/components/toast/ToastContent.tsx b/src/components/toast/ToastContent.tsx new file mode 100644 index 00000000..5e158357 --- /dev/null +++ b/src/components/toast/ToastContent.tsx @@ -0,0 +1,49 @@ +import { ReactNode } from "react"; +import clsx from "clsx"; +import { ToastAction, ToastType } from "./types"; + +type ToastContentProps = { + message: ReactNode; + icon?: ReactNode; + type?: ToastType; + actions?: ToastAction[]; + progress?: number; +}; + +export const ToastContent = ({ + message, + icon, + actions, + progress, +}: ToastContentProps) => { + const hasActions = actions && actions.length > 0; + + return ( +
+ {icon && {icon}} + + {message} + {progress !== undefined && ( + {progress}% + )} + + {hasActions && ( +
+ {actions.map((action) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/components/toast/ToastExtendedContent.tsx b/src/components/toast/ToastExtendedContent.tsx new file mode 100644 index 00000000..2d4f6653 --- /dev/null +++ b/src/components/toast/ToastExtendedContent.tsx @@ -0,0 +1,123 @@ +import { useState } from "react"; +import clsx from "clsx"; +import { FileIcon } from ":/components/preview/icons/FileIcon"; +import { CircleCheckFilled } from "../icon/icons/CircleCheckFilled"; +import { Loader } from "../icon/icons/Loader"; +import { Info } from "../icon/icons/Info"; +import { ChevronDown } from "../icon/icons/ChevronDown"; +import { XMark } from "../icon/icons/XMark"; +import { ToastExtendedItem, ToastExtendedOptions } from "./types"; + +type ToastExtendedContentProps = Omit< + ToastExtendedOptions, + "autoClose" | "containerId" +>; + +const ToastExtendedItemRow = ({ item }: { item: ToastExtendedItem }) => { + return ( +
  • + + {item.icon ?? ( + + )} + + + {item.title} + {item.size && ( + {item.size} + )} + + + {item.status === "completed" ? ( + + ) : ( + + )} + +
  • + ); +}; + +export const ToastExtendedContent = ({ + items, + summary, + progress, + onClose, + onInfoClick, +}: ToastExtendedContentProps) => { + const [expanded, setExpanded] = useState(true); + + return ( +
    +
    +
    +
      + {items.map((item) => ( + + ))} +
    +
    +
    +
    +
    + {summary} + {progress !== undefined && ( + + {progress}% + + )} + +
    +
    + + +
    +
    +
    + ); +}; diff --git a/src/components/toast/ToastProvider.tsx b/src/components/toast/ToastProvider.tsx new file mode 100644 index 00000000..7a2283b0 --- /dev/null +++ b/src/components/toast/ToastProvider.tsx @@ -0,0 +1,36 @@ +import { ToastContainer, Slide } from "react-toastify"; + +export type ToastProviderProps = { + position?: + | "top-right" + | "top-center" + | "top-left" + | "bottom-right" + | "bottom-center" + | "bottom-left"; + stacked?: boolean; + containerId?: string; +}; + +export const ToastProvider = ({ + position = "bottom-left", + stacked = true, + containerId, +}: ToastProviderProps) => { + return ( + + ); +}; diff --git a/src/components/toast/index.scss b/src/components/toast/index.scss new file mode 100644 index 00000000..2fc5ee76 --- /dev/null +++ b/src/components/toast/index.scss @@ -0,0 +1,420 @@ +.c__toast-container { + --toastify-toast-width: 576px; + --toastify-container-width: 576px; + + padding: 0; + + &, + &[data-stacked="true"] { + width: 576px; + max-width: calc(100vw - 32px); + } + + .c__toast { + display: flex; + align-items: center; + box-sizing: border-box; + min-height: 48px; + padding: var(--c--globals--spacings--xs, 8px); + border-radius: var(--c--globals--spacings--xs, 8px); + border: 1px solid var(--c--contextuals--border--semantic--neutral--tertiary); + background: var(--c--contextuals--background--surface--primary); + box-shadow: 0 6px 18px 0 rgba(0, 0, 0, 0.08); + margin-bottom: var(--c--globals--spacings--xs, 8px); + width: 100%; + max-width: 100%; + word-break: normal; + + &.Toastify__toast--info { + border-color: var(--c--contextuals--border--semantic--info--tertiary); + background: var(--c--contextuals--background--semantic--info--tertiary); + } + + &.Toastify__toast--success { + border-color: var(--c--contextuals--border--semantic--success--tertiary); + background: var( + --c--contextuals--background--semantic--success--tertiary + ); + } + + &.Toastify__toast--warning { + border-color: var(--c--contextuals--border--semantic--warning--tertiary); + background: var( + --c--contextuals--background--semantic--warning--tertiary + ); + } + + &.Toastify__toast--error { + border-color: var(--c--contextuals--border--semantic--error--tertiary); + background: var(--c--contextuals--background--semantic--error--tertiary); + } + } +} + +.c__toast-content { + display: flex; + align-items: center; + padding: 0 var(--c--globals--spacings--3xs, 4px); + gap: 10px; + flex: 1; + min-width: 0; + min-height: 32px; + font-family: var(--c--globals--font--families--base); +} + +.c__toast-content__icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--c--contextuals--content--semantic--brand--primary); + + .Toastify__toast--info & { + color: var(--c--contextuals--content--semantic--info--primary); + } + + .Toastify__toast--success & { + color: var(--c--contextuals--content--semantic--success--primary); + } + + .Toastify__toast--warning & { + color: var(--c--contextuals--content--semantic--warning--primary); + } + + .Toastify__toast--error & { + color: var(--c--contextuals--content--semantic--error--primary); + } +} + +.c__toast-content__message { + flex: 1 1 auto; + min-width: 0; + font-size: var(--c--globals--font--sizes--sm, 14px); + line-height: var(--c--globals--line-heights--sm, 18px); +} + +.c__toast-content__text { + font-weight: 500; + color: var(--c--contextuals--content--semantic--brand--primary); + + .Toastify__toast--info & { + color: var(--c--contextuals--content--semantic--info--primary); + } + + .Toastify__toast--success & { + color: var(--c--contextuals--content--semantic--success--primary); + } + + .Toastify__toast--warning & { + color: var(--c--contextuals--content--semantic--warning--primary); + } + + .Toastify__toast--error & { + color: var(--c--contextuals--content--semantic--error--primary); + } +} + +.c__toast-content__progress { + font-weight: 400; + margin-left: 10px; + white-space: nowrap; + color: var(--c--contextuals--content--semantic--brand--secondary); + + .Toastify__toast--info & { + color: var(--c--contextuals--content--semantic--info--secondary); + } + + .Toastify__toast--success & { + color: var(--c--contextuals--content--semantic--success--secondary); + } + + .Toastify__toast--warning & { + color: var(--c--contextuals--content--semantic--warning--secondary); + } + + .Toastify__toast--error & { + color: var(--c--contextuals--content--semantic--error--secondary); + } +} + +.c__toast-content__actions { + display: flex; + align-items: center; + gap: 7px; + flex-shrink: 0; + margin-left: auto; +} + +.c__toast-content__action { + cursor: pointer; + display: flex; + height: 32px; + min-width: 32px; + padding: 0 var(--c--globals--spacings--xs, 8px); + justify-content: center; + align-items: center; + border: 1px solid transparent; + background: transparent; + font-family: inherit; + font-size: var(--c--globals--font--sizes--base, 16px); + font-weight: 500; + line-height: var(--c--globals--line-heights--md, 22px); + color: var(--c--contextuals--content--semantic--brand--tertiary); + border-radius: var(--c--globals--spacings--3xs, 4px); + + .Toastify__toast--info & { + color: var(--c--contextuals--content--semantic--info--tertiary); + } + + .Toastify__toast--success & { + color: var(--c--contextuals--content--semantic--success--tertiary); + } + + .Toastify__toast--warning & { + color: var(--c--contextuals--content--semantic--warning--tertiary); + } + + .Toastify__toast--error & { + color: var(--c--contextuals--content--semantic--error--tertiary); + } + + &:hover { + background: var(--c--contextuals--background--semantic--brand--tertiary); + } + + .Toastify__toast--info &:hover { + background: var(--c--contextuals--background--semantic--info--secondary); + } + + .Toastify__toast--success &:hover { + background: var(--c--contextuals--background--semantic--success--secondary); + } + + .Toastify__toast--warning &:hover { + background: var(--c--contextuals--background--semantic--warning--secondary); + } + + .Toastify__toast--error &:hover { + background: var(--c--contextuals--background--semantic--error--secondary); + } + + &:focus-visible { + outline: 2px solid var(--c--contextuals--border--semantic--brand--primary); + outline-offset: 2px; + } +} + +.c__toast.c__toast--extended { + min-height: auto; + padding: var(--c--globals--spacings--xs, 8px); + border: 1px solid var(--c--contextuals--border--semantic--brand--tertiary); + background: var(--c--contextuals--background--semantic--brand--tertiary); + border-radius: var(--c--globals--spacings--xs, 8px); + box-shadow: 0 6px 18px 0 rgba(0, 0, 145, 0.05); + align-items: stretch; +} + +.c__toast-extended { + display: flex; + flex-direction: column; + gap: 0; + width: 100%; + font-family: var(--c--globals--font--families--base); + transition: gap 0.2s ease; + + &--expanded { + gap: 10px; + } +} + +.c__toast-extended__list-wrapper { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.2s ease; + + &--expanded { + grid-template-rows: 1fr; + } +} + +.c__toast-extended__list-inner { + min-height: 0; + overflow: hidden; +} + +.c__toast-extended__list { + display: flex; + flex-direction: column; + margin: 0; + padding: 0 var(--c--globals--spacings--xs, 8px); + list-style: none; + background: var(--c--contextuals--background--surface--primary); + border-radius: var(--c--globals--spacings--3xs, 4px); + overflow: hidden; +} + +.c__toast-extended__item { + position: relative; + display: flex; + align-items: center; + gap: var(--c--globals--spacings--xs, 8px); + height: 40px; + padding: var(--c--globals--spacings--3xs, 4px) + var(--c--globals--spacings--xxs, 6px); + min-width: 0; + + &:not(:last-child)::after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 1px; + background: var(--c--contextuals--border--surface--primary); + } +} + +.c__toast-extended__item-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 24px; + height: 32px; +} + +.c__toast-extended__item-content { + display: flex; + align-items: center; + gap: var(--c--globals--spacings--xs, 8px); + flex: 1 1 0; + min-width: 0; + font-size: var(--c--globals--font--sizes--sm, 14px); + line-height: var(--c--globals--line-heights--sm, 18px); +} + +.c__toast-extended__item-title { + font-weight: 500; + color: var(--c--contextuals--content--semantic--brand--primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.c__toast-extended__item-size { + font-weight: 400; + color: var(--c--contextuals--content--semantic--brand--secondary); + white-space: nowrap; + flex-shrink: 0; +} + +.c__toast-extended__item-status { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + color: var(--c--contextuals--content--semantic--brand--tertiary); +} + +.c__toast-extended__item-loader { + animation: c__toast-extended-spin 1s linear infinite; +} + +@keyframes c__toast-extended-spin { + 100% { + transform: rotate(360deg); + } +} + +.c__toast-extended__footer { + display: flex; + align-items: center; + height: 32px; + min-height: 32px; + padding: 0; +} + +.c__toast-extended__summary { + display: flex; + align-items: center; + gap: var(--c--globals--spacings--3xs, 4px); + flex: 0 1 auto; + min-width: 0; + padding-left: var(--c--globals--spacings--3xs, 4px); + font-size: var(--c--globals--font--sizes--sm, 14px); + line-height: var(--c--globals--line-heights--sm, 18px); +} + +.c__toast-extended__summary-text { + font-weight: 500; + color: var(--c--contextuals--content--semantic--brand--primary); +} + +.c__toast-extended__summary-progress { + font-weight: 400; + color: var(--c--contextuals--content--semantic--brand--tertiary); +} + +.c__toast-extended__summary-info { + display: flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + background: transparent; + cursor: pointer; + color: var(--c--contextuals--content--semantic--brand--tertiary); + .c__toast-extended__footer + &:focus-visible { + outline: 2px solid var(--c--contextuals--border--semantic--brand--primary); + outline-offset: 2px; + border-radius: var(--c--globals--spacings--3xs, 4px); + } +} + +.c__toast-extended__footer-actions { + display: flex; + align-items: center; + gap: var(--c--globals--spacings--3xs, 4px); + flex-shrink: 0; + margin-left: auto; +} + +.c__toast-extended__footer-action { + display: flex; + align-items: center; + justify-content: center; + min-width: 32px; + height: 32px; + padding: 0 var(--c--globals--spacings--3xs, 4px); + border: 1px solid transparent; + border-radius: var(--c--globals--spacings--3xs, 4px); + background: transparent; + cursor: pointer; + color: var(--c--contextuals--content--semantic--brand--tertiary); + + &:hover { + background: var(--c--contextuals--background--semantic--brand--tertiary); + } + + &:focus-visible { + outline: 2px solid var(--c--contextuals--border--semantic--brand--primary); + outline-offset: 2px; + } + + &--toggle { + transition: transform 0.2s ease; + + &[aria-expanded="false"] { + transform: rotate(180deg); + } + } + + &--toggle svg, + &--close svg { + width: 24px; + height: 24px; + flex-shrink: 0; + } +} diff --git a/src/components/toast/index.ts b/src/components/toast/index.ts new file mode 100644 index 00000000..ff1ef565 --- /dev/null +++ b/src/components/toast/index.ts @@ -0,0 +1,11 @@ +export { toast } from "./toast"; +export { ToastProvider } from "./ToastProvider"; +export type { ToastProviderProps } from "./ToastProvider"; +export type { + ToastOptions, + ToastAction, + ToastType, + ToastExtendedOptions, + ToastExtendedItem, + ToastExtendedItemStatus, +} from "./types"; diff --git a/src/components/toast/toast.stories.tsx b/src/components/toast/toast.stories.tsx new file mode 100644 index 00000000..54a6efd6 --- /dev/null +++ b/src/components/toast/toast.stories.tsx @@ -0,0 +1,387 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Title, Description, Controls, Stories } from "@storybook/blocks"; +import { ToastProvider } from "./ToastProvider"; +import { toast } from "./toast"; +import { ArrowRight } from "../icon/icons/ArrowRight"; + +const meta: Meta = { + title: "Components/Toast", + component: ToastProvider, + tags: ["autodocs"], + parameters: { + docs: { + page: () => ( + <> + + <Description /> + <Controls /> + <Stories /> + </> + ), + description: { + component: `The Toast component displays brief notifications to the user. It wraps \`react-toastify\` with the design system's tokens and typography. + +## Installation + +Add the \`ToastProvider\` at the root of your app: + +\`\`\`tsx +import { ToastProvider } from "@gouvfr-lasuite/ui-kit"; + +function App() { + return ( + <> + <MyApp /> + <ToastProvider /> + </> + ); +} +\`\`\` + +## Basic usage + +\`\`\`tsx +import { toast } from "@gouvfr-lasuite/ui-kit"; + +// Simple toast +toast.info("Document moved successfully."); + +// Toast with icon, progress, and actions +toast.info("Document 1 moved to Document 2", { + icon: <ArrowRight size={20} />, + progress: 15, + actions: [ + { label: "See", onClick: () => navigate("/document/2") }, + { label: "Cancel", onClick: () => undoMove() }, + ], +}); +\`\`\` + +## API + +| Method | Description | +|--------|-------------| +| \`toast(message, options?)\` | Default toast | +| \`toast.info(message, options?)\` | Info variant (blue) | +| \`toast.success(message, options?)\` | Success variant (green) | +| \`toast.warning(message, options?)\` | Warning variant (orange) | +| \`toast.error(message, options?)\` | Error variant (red) | +| \`toast.dismiss(toastId?)\` | Dismiss a toast (or all) | +| \`toast.update(toastId, options)\` | Update an existing toast | +| \`toast.extended(options)\` | Extended toast with file list and summary | + +## ToastOptions + +| Prop | Type | Description | +|------|------|-------------| +| \`icon\` | \`ReactNode\` | Icon displayed on the left | +| \`type\` | \`"info" \\| "success" \\| "warning" \\| "error"\` | Toast variant | +| \`actions\` | \`ToastAction[]\` | Action buttons (See, Cancel, etc.) | +| \`progress\` | \`number\` | Progress percentage shown next to message | +| \`autoClose\` | \`number \\| false\` | Auto-dismiss delay in ms (default: 5000) | + +## ToastAction + +| Prop | Type | Description | +|------|------|-------------| +| \`label\` | \`string\` | Button text | +| \`onClick\` | \`() => void\` | Callback on click | + +## Dismissing from an action + +\`\`\`tsx +const id = toast.info("File moved", { + actions: [ + { label: "Cancel", onClick: () => { + undoMove(); + toast.dismiss(id); + }}, + ], +}); +\`\`\` + +## Extended toast (file transfers) + +Use \`toast.extended()\` to display a list of files with transfer status. The toast is **expanded by default**; the user can collapse/expand the list via the chevron (handled internally). Wire \`onClose\` to dismiss the toast. + +\`\`\`tsx +import { toast } from "@gouvfr-lasuite/ui-kit"; + +const id = toast.extended({ + summary: "3 documents in transfer", + progress: 15, + items: [ + { + id: "1", + title: "Presentation on Monet", + size: "12 GB", + mimetype: "application/vnd.ms-powerpoint", + status: "completed", + }, + { + id: "2", + title: "Flower wallpaper – Uploading", + mimetype: "image/jpeg", + status: "loading", + }, + { + id: "3", + title: "Seminar Logistics", + size: "2 MB", + mimetype: "application/pdf", + status: "completed", + }, + ], + onClose: () => toast.dismiss(id), + onInfoClick: () => showTransferDetails(), + autoClose: false, +}); +\`\`\` + +To update progress as uploads complete, use \`toast.update()\` with a new \`ToastExtendedContent\` render (or re-call \`toast.extended\` after dismissing the previous one). + +## ToastExtendedOptions + +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| \`items\` | \`ToastExtendedItem[]\` | Yes | List of files to display | +| \`summary\` | \`string\` | No | Footer text (e.g. "3 documents in transfer") | +| \`progress\` | \`number\` | No | Global progress % shown next to summary | +| \`onClose\` | \`() => void\` | No | Called when user clicks the X button | +| \`onInfoClick\` | \`() => void\` | No | Called when user clicks the info icon | +| \`autoClose\` | \`number \\| false\` | No | Auto-dismiss delay in ms (default: \`false\`) | +| \`containerId\` | \`string\` | No | Target a specific ToastContainer (e.g. Storybook) | + +## ToastExtendedItem + +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| \`id\` | \`string\` | No | Unique key for the row | +| \`title\` | \`string\` | Yes | File name or label | +| \`size\` | \`string\` | No | File size (e.g. "12 GB", "2 MB") | +| \`mimetype\` | \`string\` | No | MIME type for automatic file icon | +| \`status\` | \`"completed" \\| "loading"\` | Yes | Shows checkmark or spinner | +| \`icon\` | \`ReactNode\` | No | Custom icon instead of mime-based FileIcon | +`, + }, + }, + }, + decorators: [ + (Story) => ( + <div style={{ minHeight: 400, position: "relative" }}> + <Story /> + </div> + ), + ], +}; + +export default meta; +type Story = StoryObj<typeof ToastProvider>; + +export const Default: Story = { + name: "Info with actions", + render: () => { + const containerId = "story-info-actions"; + return ( + <> + <button + className="c__button" + onClick={() => { + const id = toast.info("Document 1 moved to Document 2", { + icon: <ArrowRight size={20} />, + progress: 15, + autoClose: false, + containerId, + actions: [ + { label: "See", onClick: () => alert("Navigate to document") }, + { + label: "Cancel", + onClick: () => toast.dismiss(id), + }, + ], + }); + }} + > + Show toast + </button> + <ToastProvider position="bottom-left" containerId={containerId} /> + </> + ); + }, +}; + +export const WithProgress: Story = { + name: "With progress", + render: () => { + const containerId = "story-progress"; + return ( + <> + <button + className="c__button" + onClick={() => + toast.info("Uploading document...", { + icon: <ArrowRight size={20} />, + progress: 45, + autoClose: false, + containerId, + }) + } + > + Show progress toast + </button> + <ToastProvider position="bottom-left" containerId={containerId} /> + </> + ); + }, +}; + +export const Success: Story = { + render: () => { + const containerId = "story-success"; + return ( + <> + <button + className="c__button" + onClick={() => + toast.success("Document successfully uploaded.", { + icon: <span className="material-icons">check_circle</span>, + containerId, + }) + } + > + Show success toast + </button> + <ToastProvider position="bottom-left" containerId={containerId} /> + </> + ); + }, +}; + +export const Warning: Story = { + render: () => { + const containerId = "story-warning"; + return ( + <> + <button + className="c__button" + onClick={() => + toast.warning("Storage is almost full.", { + icon: <span className="material-icons">warning</span>, + containerId, + }) + } + > + Show warning toast + </button> + <ToastProvider position="bottom-left" containerId={containerId} /> + </> + ); + }, +}; + +export const Error: Story = { + render: () => { + const containerId = "story-error"; + return ( + <> + <button + className="c__button" + onClick={() => + toast.error("Upload failed. Please try again.", { + icon: <span className="material-icons">error</span>, + containerId, + }) + } + > + Show error toast + </button> + <ToastProvider position="bottom-left" containerId={containerId} /> + </> + ); + }, +}; + +export const Stacked: Story = { + name: "Multiple stacked", + render: () => { + const containerId = "story-stacked"; + let count = 0; + return ( + <> + <button + className="c__button" + onClick={() => { + count++; + toast.info(`Document ${count} moved to Folder`, { + icon: <ArrowRight size={20} />, + actions: [ + { label: "See", onClick: () => {} }, + { label: "Cancel", onClick: () => {} }, + ], + progress: Math.round(Math.random() * 100), + containerId, + }); + }} + > + Add toast (click multiple times) + </button> + <ToastProvider position="bottom-left" containerId={containerId} /> + </> + ); + }, +}; + +export const Extended: Story = { + name: "Extended", + render: () => { + const containerId = "story-extended"; + return ( + <> + <button + className="c__button" + onClick={() => { + const id = toast.extended({ + containerId, + summary: "3 documents in transfer", + progress: 15, + items: [ + { + id: "1", + title: "Presentation on Monet", + size: "12 GB", + mimetype: "application/vnd.ms-powerpoint", + status: "completed", + }, + { + id: "2", + title: "Flower wallpaper – Uploading", + mimetype: "image/jpeg", + status: "loading", + }, + { + id: "3", + title: "Seminar Logistics", + size: "2 MB", + mimetype: "application/pdf", + status: "completed", + }, + { + id: "4", + title: "Essay on the Vosges", + size: "10 MB", + mimetype: "application/pdf", + status: "completed", + }, + ], + onClose: () => toast.dismiss(id), + onInfoClick: () => alert("More information"), + }); + }} + > + Show extended toast + </button> + <ToastProvider position="bottom-left" containerId={containerId} /> + </> + ); + }, +}; diff --git a/src/components/toast/toast.tsx b/src/components/toast/toast.tsx new file mode 100644 index 00000000..c831bcfd --- /dev/null +++ b/src/components/toast/toast.tsx @@ -0,0 +1,54 @@ +import { ReactNode } from "react"; +import { toast as reactToastify, Id } from "react-toastify"; +import { ToastContent } from "./ToastContent"; +import { ToastExtendedContent } from "./ToastExtendedContent"; +import { ToastExtendedOptions, ToastOptions } from "./types"; + +export const toast = (message: ReactNode, options?: ToastOptions): Id => { + return reactToastify( + <ToastContent + message={message} + icon={options?.icon} + type={options?.type} + actions={options?.actions} + progress={options?.progress} + />, + { + type: options?.type ?? "default", + autoClose: options?.autoClose ?? 5000, + icon: false, + containerId: options?.containerId, + }, + ); +}; + +toast.info = (message: ReactNode, options?: Omit<ToastOptions, "type">): Id => + toast(message, { ...options, type: "info" }); + +toast.success = ( + message: ReactNode, + options?: Omit<ToastOptions, "type">, +): Id => toast(message, { ...options, type: "success" }); + +toast.warning = ( + message: ReactNode, + options?: Omit<ToastOptions, "type">, +): Id => toast(message, { ...options, type: "warning" }); + +toast.error = ( + message: ReactNode, + options?: Omit<ToastOptions, "type">, +): Id => toast(message, { ...options, type: "error" }); + +toast.extended = (options: ToastExtendedOptions): Id => { + return reactToastify(<ToastExtendedContent {...options} />, { + type: "default", + autoClose: options.autoClose ?? false, + icon: false, + containerId: options.containerId, + className: "c__toast c__toast--extended", + }); +}; + +toast.update = reactToastify.update; +toast.dismiss = reactToastify.dismiss; diff --git a/src/components/toast/types.ts b/src/components/toast/types.ts new file mode 100644 index 00000000..8c03568c --- /dev/null +++ b/src/components/toast/types.ts @@ -0,0 +1,38 @@ +import { ReactNode } from "react"; + +export type ToastType = "info" | "success" | "warning" | "error"; + +export type ToastAction = { + label: string; + onClick: () => void; +}; + +export type ToastOptions = { + icon?: ReactNode; + type?: ToastType; + actions?: ToastAction[]; + progress?: number; + autoClose?: number | false; + containerId?: string; +}; + +export type ToastExtendedItemStatus = "completed" | "loading"; + +export type ToastExtendedItem = { + id?: string; + title: string; + size?: string; + mimetype?: string; + status: ToastExtendedItemStatus; + icon?: ReactNode; +}; + +export type ToastExtendedOptions = { + items: ToastExtendedItem[]; + summary?: string; + progress?: number; + onClose?: () => void; + onInfoClick?: () => void; + autoClose?: number | false; + containerId?: string; +}; diff --git a/src/index.ts b/src/index.ts index 36ca7f0c..2e7be177 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ export * from "./components/tabs"; export * from "./components/tree-view"; export * from "./components/form/label/label"; export * from "./components/badge"; +export * from "./components/toast"; export * from "./components/icon"; export * from "./components/help-menu"; export * from "./components/feedback-form"; diff --git a/src/library.scss b/src/library.scss index 2f11ab7c..029652aa 100644 --- a/src/library.scss +++ b/src/library.scss @@ -38,6 +38,7 @@ @use "./components/share/modal/share-modal"; @use "./components/modal"; @use "./components/badge"; +@use "./components/toast"; @use "./components/icon"; @use "./components/help-menu"; @use "./components/feedback-form"; diff --git a/yarn.lock b/yarn.lock index d6c64330..36932b33 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5177,7 +5177,7 @@ cliui@^7.0.2: strip-ansi "^6.0.0" wrap-ansi "^7.0.0" -clsx@2.1.1, clsx@^2.0.0: +clsx@2.1.1, clsx@^2.0.0, clsx@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== @@ -8034,6 +8034,13 @@ react-style-singleton@^2.2.2, react-style-singleton@^2.2.3: get-nonce "^1.0.0" tslib "^2.0.0" +react-toastify@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/react-toastify/-/react-toastify-11.1.0.tgz#3d3c73a44d5cac868ee9a52e0f90dd706532c4a4" + integrity sha512-e9h23x3phN0wbFeB6yovmWp7lobzV4CaCH0LO8nVP6H7Y+3GbcLpIzMm9dJhcp1RXbpyfvjgpfXqO80QAmn7sg== + dependencies: + clsx "^2.1.1" + react-virtualized@9.22.6: version "9.22.6" resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.22.6.tgz#3ae2aa69eca61cf3af332e2f9d6b4aa5638786d5"