From 89c5f1741d9c637de221509175c4a7e5ac78265e Mon Sep 17 00:00:00 2001 From: Cyril Date: Mon, 6 Jul 2026 16:37:05 +0200 Subject: [PATCH 01/10] =?UTF-8?q?=F0=9F=93=A6(front)=20install=20react-toa?= =?UTF-8?q?stify=20dependency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add react-toastify ^11.1.0 for toast notification support --- package.json | 1 + yarn.lock | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) 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/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" From a63c8f4886bccf177178843d7ab7b406d02517f2 Mon Sep 17 00:00:00 2001 From: Cyril Date: Mon, 6 Jul 2026 16:39:32 +0200 Subject: [PATCH 02/10] =?UTF-8?q?=E2=9C=A8(toast)=20add=20Toast=20componen?= =?UTF-8?q?t=20with=20react-toastify=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement ToastContent, ToastProvider, toast API and TypeScript types --- src/components/toast/ToastContent.tsx | 49 ++++++++++++++++++++++++++ src/components/toast/ToastProvider.tsx | 33 +++++++++++++++++ src/components/toast/index.ts | 4 +++ src/components/toast/toast.tsx | 42 ++++++++++++++++++++++ src/components/toast/types.ts | 16 +++++++++ 5 files changed, 144 insertions(+) create mode 100644 src/components/toast/ToastContent.tsx create mode 100644 src/components/toast/ToastProvider.tsx create mode 100644 src/components/toast/index.ts create mode 100644 src/components/toast/toast.tsx create mode 100644 src/components/toast/types.ts 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/ToastProvider.tsx b/src/components/toast/ToastProvider.tsx new file mode 100644 index 00000000..f8e6c7aa --- /dev/null +++ b/src/components/toast/ToastProvider.tsx @@ -0,0 +1,33 @@ +import { ToastContainer, Slide } from "react-toastify"; + +export type ToastProviderProps = { + position?: + | "top-right" + | "top-center" + | "top-left" + | "bottom-right" + | "bottom-center" + | "bottom-left"; + stacked?: boolean; +}; + +export const ToastProvider = ({ + position = "bottom-left", + stacked = true, +}: ToastProviderProps) => { + return ( + + ); +}; diff --git a/src/components/toast/index.ts b/src/components/toast/index.ts new file mode 100644 index 00000000..969f2e69 --- /dev/null +++ b/src/components/toast/index.ts @@ -0,0 +1,4 @@ +export { toast } from "./toast"; +export { ToastProvider } from "./ToastProvider"; +export type { ToastProviderProps } from "./ToastProvider"; +export type { ToastOptions, ToastAction, ToastType } from "./types"; diff --git a/src/components/toast/toast.tsx b/src/components/toast/toast.tsx new file mode 100644 index 00000000..36ac20c1 --- /dev/null +++ b/src/components/toast/toast.tsx @@ -0,0 +1,42 @@ +import { ReactNode } from "react"; +import { toast as reactToastify, Id } from "react-toastify"; +import { ToastContent } from "./ToastContent"; +import { ToastOptions } from "./types"; + +export const toast = (message: ReactNode, options?: ToastOptions): Id => { + return reactToastify( + , + { + type: options?.type ?? "default", + autoClose: options?.autoClose ?? 5000, + icon: false, + }, + ); +}; + +toast.info = (message: ReactNode, options?: Omit): Id => + toast(message, { ...options, type: "info" }); + +toast.success = ( + message: ReactNode, + options?: Omit, +): Id => toast(message, { ...options, type: "success" }); + +toast.warning = ( + message: ReactNode, + options?: Omit, +): Id => toast(message, { ...options, type: "warning" }); + +toast.error = ( + message: ReactNode, + options?: Omit, +): Id => toast(message, { ...options, type: "error" }); + +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..4282a4cf --- /dev/null +++ b/src/components/toast/types.ts @@ -0,0 +1,16 @@ +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; +}; From e41bf10fc36ea6d539b142d8c13bb0945d888a82 Mon Sep 17 00:00:00 2001 From: Cyril Date: Tue, 7 Jul 2026 09:14:42 +0200 Subject: [PATCH 03/10] =?UTF-8?q?=F0=9F=92=84(toast)=20add=20styles=20with?= =?UTF-8?q?=20Cunningham=20design=20tokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SCSS matching Figma specs with semantic color variants and token usage --- src/components/toast/index.scss | 205 ++++++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 src/components/toast/index.scss diff --git a/src/components/toast/index.scss b/src/components/toast/index.scss new file mode 100644 index 00000000..632c96c8 --- /dev/null +++ b/src/components/toast/index.scss @@ -0,0 +1,205 @@ +.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; + } +} From cb7e6e31c5bd3bb5536cd7b83d7e825e56dbf52f Mon Sep 17 00:00:00 2001 From: Cyril Date: Tue, 7 Jul 2026 09:15:07 +0200 Subject: [PATCH 04/10] =?UTF-8?q?=E2=9C=A8(toast)=20export=20component=20a?= =?UTF-8?q?nd=20import=20styles=20in=20library?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register toast in src/index.ts and src/library.scss entry points --- src/index.ts | 1 + src/library.scss | 1 + 2 files changed, 2 insertions(+) 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"; From 45efac5e27c4329bbcde61cfcaac644a26ac9c7c Mon Sep 17 00:00:00 2001 From: Cyril Date: Tue, 7 Jul 2026 09:15:27 +0200 Subject: [PATCH 05/10] =?UTF-8?q?=E2=9C=A8(toast)=20export=20component=20a?= =?UTF-8?q?nd=20import=20styles=20in=20library?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register toast in src/index.ts and src/library.scss entry points --- src/components/toast/toast.stories.tsx | 301 +++++++++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 src/components/toast/toast.stories.tsx diff --git a/src/components/toast/toast.stories.tsx b/src/components/toast/toast.stories.tsx new file mode 100644 index 00000000..fdbab698 --- /dev/null +++ b/src/components/toast/toast.stories.tsx @@ -0,0 +1,301 @@ +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 | + +## 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); + }}, + ], +}); +\`\`\` +`, + }, + }, + }, + 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={() => + toast.info("Document 1 moved to Document 2", { + icon: <ArrowRight size={20} />, + actions: [ + { label: "See", onClick: () => alert("Navigate to document") }, + { + label: "Cancel", + onClick: () => alert("Action cancelled"), + }, + ], + progress: 15, + containerId, + }) + } + > + 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 DismissExample: Story = { + name: "Dismiss on action", + render: () => { + const containerId = "story-dismiss"; + 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 (Cancel dismisses) + </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} /> + </> + ); + }, +}; From e3c20b9b708eb8275f213fd15ebb109c2f6351bb Mon Sep 17 00:00:00 2001 From: Cyril <c.gromoff@gmail.com> Date: Tue, 7 Jul 2026 09:16:21 +0200 Subject: [PATCH 06/10] =?UTF-8?q?=F0=9F=90=9B(stories)=20fix=20toast=20dis?= =?UTF-8?q?patch=20in=20Docs=20with=20containerId=20isolation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use unique containerIds per story to prevent conflicts with multiple ToastContainers. --- src/components/toast/ToastProvider.tsx | 3 +++ src/components/toast/toast.tsx | 1 + src/components/toast/types.ts | 1 + 3 files changed, 5 insertions(+) diff --git a/src/components/toast/ToastProvider.tsx b/src/components/toast/ToastProvider.tsx index f8e6c7aa..7a2283b0 100644 --- a/src/components/toast/ToastProvider.tsx +++ b/src/components/toast/ToastProvider.tsx @@ -9,11 +9,13 @@ export type ToastProviderProps = { | "bottom-center" | "bottom-left"; stacked?: boolean; + containerId?: string; }; export const ToastProvider = ({ position = "bottom-left", stacked = true, + containerId, }: ToastProviderProps) => { return ( <ToastContainer @@ -28,6 +30,7 @@ export const ToastProvider = ({ draggable={false} transition={Slide} stacked={stacked} + containerId={containerId} /> ); }; diff --git a/src/components/toast/toast.tsx b/src/components/toast/toast.tsx index 36ac20c1..cd5773a3 100644 --- a/src/components/toast/toast.tsx +++ b/src/components/toast/toast.tsx @@ -16,6 +16,7 @@ export const toast = (message: ReactNode, options?: ToastOptions): Id => { type: options?.type ?? "default", autoClose: options?.autoClose ?? 5000, icon: false, + containerId: options?.containerId, }, ); }; diff --git a/src/components/toast/types.ts b/src/components/toast/types.ts index 4282a4cf..d7508a8a 100644 --- a/src/components/toast/types.ts +++ b/src/components/toast/types.ts @@ -13,4 +13,5 @@ export type ToastOptions = { actions?: ToastAction[]; progress?: number; autoClose?: number | false; + containerId?: string; }; From 3835971c5e1bbac61f19b7025c1ee95726105cb4 Mon Sep 17 00:00:00 2001 From: Cyril <c.gromoff@gmail.com> Date: Tue, 7 Jul 2026 11:11:49 +0200 Subject: [PATCH 07/10] =?UTF-8?q?=E2=9C=A8(toast)=20add=20Toast=20extended?= =?UTF-8?q?=20component=20and=20toast.extended=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement file list toast with collapse, footer actions and typed options --- src/components/toast/ToastExtendedContent.tsx | 110 ++++++++++++++++++ src/components/toast/index.ts | 9 +- src/components/toast/toast.tsx | 13 ++- src/components/toast/types.ts | 21 ++++ 4 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 src/components/toast/ToastExtendedContent.tsx diff --git a/src/components/toast/ToastExtendedContent.tsx b/src/components/toast/ToastExtendedContent.tsx new file mode 100644 index 00000000..8e98c67e --- /dev/null +++ b/src/components/toast/ToastExtendedContent.tsx @@ -0,0 +1,110 @@ +import { useState } from "react"; +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 ( + <li className="c__toast-extended__item"> + <span className="c__toast-extended__item-icon"> + {item.icon ?? ( + <FileIcon + file={{ title: item.title, mimetype: item.mimetype ?? "" }} + type="mini" + size={24} + /> + )} + </span> + <span className="c__toast-extended__item-content"> + <span className="c__toast-extended__item-title">{item.title}</span> + {item.size && ( + <span className="c__toast-extended__item-size">{item.size}</span> + )} + </span> + <span className="c__toast-extended__item-status"> + {item.status === "completed" ? ( + <CircleCheckFilled size={16} /> + ) : ( + <Loader size={16} className="c__toast-extended__item-loader" /> + )} + </span> + </li> + ); +}; + +export const ToastExtendedContent = ({ + items, + summary, + progress, + onClose, + onInfoClick, +}: ToastExtendedContentProps) => { + const [expanded, setExpanded] = useState(true); + + return ( + <div className="c__toast-extended"> + {expanded && ( + <ul className="c__toast-extended__list"> + {items.map((item) => ( + <ToastExtendedItemRow key={item.id ?? item.title} item={item} /> + ))} + </ul> + )} + <div className="c__toast-extended__footer"> + <div className="c__toast-extended__summary"> + <span className="c__toast-extended__summary-text">{summary}</span> + {progress !== undefined && ( + <span className="c__toast-extended__summary-progress"> + {progress}% + </span> + )} + <button + type="button" + className="c__toast-extended__summary-info" + onClick={(e) => { + e.stopPropagation(); + onInfoClick?.(); + }} + aria-label="More information" + > + <Info size={16} /> + </button> + </div> + <div className="c__toast-extended__footer-actions"> + <button + type="button" + className="c__toast-extended__footer-action c__toast-extended__footer-action--toggle" + onClick={(e) => { + e.stopPropagation(); + setExpanded((prev) => !prev); + }} + aria-label={expanded ? "Collapse" : "Expand"} + aria-expanded={expanded} + > + <ChevronDown size={16} /> + </button> + <button + type="button" + className="c__toast-extended__footer-action" + onClick={(e) => { + e.stopPropagation(); + onClose?.(); + }} + aria-label="Close" + > + <XMark size={16} /> + </button> + </div> + </div> + </div> + ); +}; diff --git a/src/components/toast/index.ts b/src/components/toast/index.ts index 969f2e69..ff1ef565 100644 --- a/src/components/toast/index.ts +++ b/src/components/toast/index.ts @@ -1,4 +1,11 @@ export { toast } from "./toast"; export { ToastProvider } from "./ToastProvider"; export type { ToastProviderProps } from "./ToastProvider"; -export type { ToastOptions, ToastAction, ToastType } from "./types"; +export type { + ToastOptions, + ToastAction, + ToastType, + ToastExtendedOptions, + ToastExtendedItem, + ToastExtendedItemStatus, +} from "./types"; diff --git a/src/components/toast/toast.tsx b/src/components/toast/toast.tsx index cd5773a3..c831bcfd 100644 --- a/src/components/toast/toast.tsx +++ b/src/components/toast/toast.tsx @@ -1,7 +1,8 @@ import { ReactNode } from "react"; import { toast as reactToastify, Id } from "react-toastify"; import { ToastContent } from "./ToastContent"; -import { ToastOptions } from "./types"; +import { ToastExtendedContent } from "./ToastExtendedContent"; +import { ToastExtendedOptions, ToastOptions } from "./types"; export const toast = (message: ReactNode, options?: ToastOptions): Id => { return reactToastify( @@ -39,5 +40,15 @@ toast.error = ( 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 index d7508a8a..8c03568c 100644 --- a/src/components/toast/types.ts +++ b/src/components/toast/types.ts @@ -15,3 +15,24 @@ export type ToastOptions = { 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; +}; From 5e63596662a03d0c27177fb3c818e89b044bc7c8 Mon Sep 17 00:00:00 2001 From: Cyril <c.gromoff@gmail.com> Date: Tue, 7 Jul 2026 11:29:09 +0200 Subject: [PATCH 08/10] =?UTF-8?q?=F0=9F=92=84(toast)=20add=20extended=20to?= =?UTF-8?q?ast=20styles=20matching=20Figma=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brand background, white list, separators, 32px footer and token colors --- src/components/toast/index.scss | 194 ++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) diff --git a/src/components/toast/index.scss b/src/components/toast/index.scss index 632c96c8..e73c75e3 100644 --- a/src/components/toast/index.scss +++ b/src/components/toast/index.scss @@ -203,3 +203,197 @@ 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: 10px; + width: 100%; + font-family: var(--c--globals--font--families--base); +} + +.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; + 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; + } +} From 4781c8c90e8457d7627f9a23efee6f71cbd1607f Mon Sep 17 00:00:00 2001 From: Cyril <c.gromoff@gmail.com> Date: Tue, 7 Jul 2026 11:29:34 +0200 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=93=9D(stories)=20add=20Toast=20ext?= =?UTF-8?q?ended=20story=20and=20usage=20documentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storybook demo with mime icons, progress summary and API tables in Docs --- src/components/toast/ToastExtendedContent.tsx | 6 +- src/components/toast/toast.stories.tsx | 156 ++++++++++++++---- 2 files changed, 124 insertions(+), 38 deletions(-) diff --git a/src/components/toast/ToastExtendedContent.tsx b/src/components/toast/ToastExtendedContent.tsx index 8e98c67e..9810c16f 100644 --- a/src/components/toast/ToastExtendedContent.tsx +++ b/src/components/toast/ToastExtendedContent.tsx @@ -90,18 +90,18 @@ export const ToastExtendedContent = ({ aria-label={expanded ? "Collapse" : "Expand"} aria-expanded={expanded} > - <ChevronDown size={16} /> + <ChevronDown size={24} /> </button> <button type="button" - className="c__toast-extended__footer-action" + className="c__toast-extended__footer-action c__toast-extended__footer-action--close" onClick={(e) => { e.stopPropagation(); onClose?.(); }} aria-label="Close" > - <XMark size={16} /> + <XMark size={24} /> </button> </div> </div> diff --git a/src/components/toast/toast.stories.tsx b/src/components/toast/toast.stories.tsx index fdbab698..54a6efd6 100644 --- a/src/components/toast/toast.stories.tsx +++ b/src/components/toast/toast.stories.tsx @@ -68,6 +68,7 @@ toast.info("Document 1 moved to Document 2", { | \`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 @@ -98,6 +99,69 @@ const id = toast.info("File moved", { ], }); \`\`\` + +## 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 | `, }, }, @@ -122,20 +186,21 @@ export const Default: Story = { <> <button className="c__button" - onClick={() => - toast.info("Document 1 moved to Document 2", { + 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: () => alert("Action cancelled"), + onClick: () => toast.dismiss(id), }, ], - progress: 15, - containerId, - }) - } + }); + }} > Show toast </button> @@ -236,33 +301,29 @@ export const Error: Story = { }, }; -export const DismissExample: Story = { - name: "Dismiss on action", +export const Stacked: Story = { + name: "Multiple stacked", render: () => { - const containerId = "story-dismiss"; + const containerId = "story-stacked"; + let count = 0; return ( <> <button className="c__button" onClick={() => { - const id = toast.info("Document 1 moved to Document 2", { + count++; + toast.info(`Document ${count} moved to Folder`, { icon: <ArrowRight size={20} />, - progress: 15, - autoClose: false, - containerId, actions: [ - { label: "See", onClick: () => alert("Navigate to document") }, - { - label: "Cancel", - onClick: () => { - toast.dismiss(id); - }, - }, + { label: "See", onClick: () => {} }, + { label: "Cancel", onClick: () => {} }, ], + progress: Math.round(Math.random() * 100), + containerId, }); }} > - Show toast (Cancel dismisses) + Add toast (click multiple times) </button> <ToastProvider position="bottom-left" containerId={containerId} /> </> @@ -270,29 +331,54 @@ export const DismissExample: Story = { }, }; -export const Stacked: Story = { - name: "Multiple stacked", +export const Extended: Story = { + name: "Extended", render: () => { - const containerId = "story-stacked"; - let count = 0; + const containerId = "story-extended"; 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), + 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"), }); }} > - Add toast (click multiple times) + Show extended toast </button> <ToastProvider position="bottom-left" containerId={containerId} /> </> From 90e3e6b51a1d29ed915a5e4ddc0aa2070e062fb4 Mon Sep 17 00:00:00 2001 From: Cyril <c.gromoff@gmail.com> Date: Tue, 7 Jul 2026 14:12:12 +0200 Subject: [PATCH 10/10] =?UTF-8?q?=E2=9C=A8(toast)=20animate=20extended=20t?= =?UTF-8?q?oast=20list=20collapse=20and=20expand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smooth 0.2s transition on list height and gap when toggling chevron --- src/components/toast/ToastExtendedContent.tsx | 27 ++++++++++++++----- src/components/toast/index.scss | 23 +++++++++++++++- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/components/toast/ToastExtendedContent.tsx b/src/components/toast/ToastExtendedContent.tsx index 9810c16f..2d4f6653 100644 --- a/src/components/toast/ToastExtendedContent.tsx +++ b/src/components/toast/ToastExtendedContent.tsx @@ -1,4 +1,5 @@ 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"; @@ -51,14 +52,26 @@ export const ToastExtendedContent = ({ const [expanded, setExpanded] = useState(true); return ( - <div className="c__toast-extended"> - {expanded && ( - <ul className="c__toast-extended__list"> - {items.map((item) => ( - <ToastExtendedItemRow key={item.id ?? item.title} item={item} /> - ))} - </ul> + <div + className={clsx( + "c__toast-extended", + expanded && "c__toast-extended--expanded", )} + > + <div + className={clsx( + "c__toast-extended__list-wrapper", + expanded && "c__toast-extended__list-wrapper--expanded", + )} + > + <div className="c__toast-extended__list-inner"> + <ul className="c__toast-extended__list" aria-hidden={!expanded}> + {items.map((item) => ( + <ToastExtendedItemRow key={item.id ?? item.title} item={item} /> + ))} + </ul> + </div> + </div> <div className="c__toast-extended__footer"> <div className="c__toast-extended__summary"> <span className="c__toast-extended__summary-text">{summary}</span> diff --git a/src/components/toast/index.scss b/src/components/toast/index.scss index e73c75e3..2fc5ee76 100644 --- a/src/components/toast/index.scss +++ b/src/components/toast/index.scss @@ -217,9 +217,29 @@ .c__toast-extended { display: flex; flex-direction: column; - gap: 10px; + 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 { @@ -321,6 +341,7 @@ 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); }