diff --git a/apps/remix-ide-e2e/src/commands/waitForElementContainsText.ts b/apps/remix-ide-e2e/src/commands/waitForElementContainsText.ts index 6ce01beaa01..c8076c7939f 100644 --- a/apps/remix-ide-e2e/src/commands/waitForElementContainsText.ts +++ b/apps/remix-ide-e2e/src/commands/waitForElementContainsText.ts @@ -1,25 +1,75 @@ import { NightwatchBrowser } from 'nightwatch' import EventEmitter from 'events' +const findElementsAsync = (browser: NightwatchBrowser, selector: string): Promise => { + return new Promise((resolve, reject) => { + browser.findElements(selector, (result) => { + resolve(result.value as any) + }) + }) +} + +const getTextAsync = (browser: NightwatchBrowser, elementId: string): Promise => { + return new Promise((resolve, reject) => { + browser.getText(elementId, (result) => { + const text = typeof result === 'string' ? result : result.value + resolve(text as any) + }) + }) +} + + class WaitForElementContainsText extends EventEmitter { command (this: NightwatchBrowser, id: string, value: string, timeout = 10000): NightwatchBrowser { let waitId // eslint-disable-line - let currentValue - const runid = setInterval(() => { - this.api.getText(id, (result) => { - currentValue = result.value - if (typeof result.value === 'string' && result.value.indexOf(value) !== -1) { + let currentValues: string[] = [] + const runid = setInterval(async () => { + try { + + let elements = await findElementsAsync(this.api, id) + + if (!elements) { + currentValues = [] + return + } + + if (elements.length === 0) { + currentValues = [] + return + } + + // Check all elements that match the selector + let foundMatch = false + const textValues: string[] = [] + + for (const element of elements) { + let text = await getTextAsync(this.api, element) + currentValues.push(text) + + if (typeof text === 'string' && text.indexOf(value) !== -1) { + foundMatch = true + break + } + } + + currentValues = textValues + + if (foundMatch) { clearInterval(runid) clearTimeout(waitId) this.api.assert.ok(true, `WaitForElementContainsText ${id} contains ${value}`) this.emit('complete') } - }) + } catch (err) { + // Ignore errors and continue polling + console.error(`Error in waitForElementContainsText for selector ${id}:`, err) + } }, 200) waitId = setTimeout(() => { clearInterval(runid) - this.api.assert.fail(`TimeoutError: An error occurred while running .waitForElementContainsText() command on ${id} after ${timeout} milliseconds. expected: ${value} - got: ${currentValue}`) + const valuesFound = currentValues.length > 0 ? currentValues.join(', ') : 'none' + this.api.assert.fail(`TimeoutError: An error occurred while running .waitForElementContainsText() command on ${id} after ${timeout} milliseconds. expected: ${value} - got: ${valuesFound}`) }, timeout) return this } diff --git a/apps/remix-ide/src/app/plugins/notification.tsx b/apps/remix-ide/src/app/plugins/notification.tsx index c37b74d0a61..d099fe578dd 100644 --- a/apps/remix-ide/src/app/plugins/notification.tsx +++ b/apps/remix-ide/src/app/plugins/notification.tsx @@ -4,14 +4,15 @@ import { LibraryProfile, MethodApi, StatusEvents } from '@remixproject/plugin-ut import { AppModal } from '@remix-ui/app' import { AlertModal } from '@remix-ui/app' import { dispatchModalInterface } from '@remix-ui/app' +import { Toaster, toast } from '@remix-ui/toaster' interface INotificationApi { events: StatusEvents methods: { modal: (args: AppModal) => void alert: (args: AlertModal) => void - toast: (message: string) => void - + toast: (message: string) => number + hideToaster: (id: number) => void } } @@ -19,13 +20,15 @@ const profile: LibraryProfile = { name: 'notification', displayName: 'Notification', description: 'Displays notifications', - methods: ['modal', 'alert', 'toast'] + methods: ['modal', 'alert', 'toast', 'hideToaster'] } export class NotificationPlugin extends Plugin implements MethodApi { dispatcher: dispatchModalInterface + toastId: number constructor() { super(profile) + this.toastId = 0 } setDispatcher(dispatcher: dispatchModalInterface) { @@ -40,7 +43,14 @@ export class NotificationPlugin extends Plugin implements MethodApi { + timestamp = timestamp || Date.now() + timestamp = timestamp + ++this.toastId + this.dispatcher.toast(message, timeout, timestamp) + return timestamp + } + + async hideToaster(id: number) { + toast.dismiss('toast-' + id) } } diff --git a/apps/remix-ide/src/assets/css/themes/remix-dark_tvx1s2.css b/apps/remix-ide/src/assets/css/themes/remix-dark_tvx1s2.css index bda4bbc9a6e..2a5dfb2ac57 100644 --- a/apps/remix-ide/src/assets/css/themes/remix-dark_tvx1s2.css +++ b/apps/remix-ide/src/assets/css/themes/remix-dark_tvx1s2.css @@ -5596,14 +5596,14 @@ textarea.form-control-lg { --bs-toast-spacing: 1.5rem; --bs-toast-max-width: 350px; --bs-toast-font-size: 0.875rem; - --bs-toast-color: ; - --bs-toast-bg: #444; + --bs-toast-color: var(--bs-body-color); + --bs-toast-bg: var(--bs-light); --bs-toast-border-width: var(--bs-border-width); --bs-toast-border-color: var(--bs-border-color-translucent); --bs-toast-border-radius: var(--bs-border-radius); --bs-toast-box-shadow: var(--bs-box-shadow); --bs-toast-header-color: var(--bs-secondary-color); - --bs-toast-header-bg: #303030; + --bs-toast-header-bg: var(--bs-body-bg); --bs-toast-header-border-color: var(--bs-border-color-translucent); width: var(--bs-toast-max-width); max-width: 100%; diff --git a/apps/remix-ide/src/assets/css/themes/remix-light_powaqg.css b/apps/remix-ide/src/assets/css/themes/remix-light_powaqg.css index c7c87781995..7e6201eb42d 100644 --- a/apps/remix-ide/src/assets/css/themes/remix-light_powaqg.css +++ b/apps/remix-ide/src/assets/css/themes/remix-light_powaqg.css @@ -5598,14 +5598,14 @@ textarea.form-control-lg { --bs-toast-spacing: 1.5rem; --bs-toast-max-width: 350px; --bs-toast-font-size: 0.875rem; - --bs-toast-color: ; - --bs-toast-bg: rgba(var(--bs-body-bg-rgb), 0.85); + --bs-toast-color: var(--bs-body-color); + --bs-toast-bg: var(--bs-light); --bs-toast-border-width: var(--bs-border-width); --bs-toast-border-color: var(--bs-border-color-translucent); --bs-toast-border-radius: var(--bs-border-radius); --bs-toast-box-shadow: var(--bs-box-shadow); --bs-toast-header-color: var(--bs-secondary-color); - --bs-toast-header-bg: rgba(var(--bs-body-bg-rgb), 0.85); + --bs-toast-header-bg: var(--bs-body-bg); --bs-toast-header-border-color: var(--bs-border-color-translucent); width: var(--bs-toast-max-width); max-width: 100%; diff --git a/libs/remix-ui/app/src/lib/remix-app/actions/modals.ts b/libs/remix-ui/app/src/lib/remix-app/actions/modals.ts index c4fed0d70e9..5fb5dda57d0 100644 --- a/libs/remix-ui/app/src/lib/remix-app/actions/modals.ts +++ b/libs/remix-ui/app/src/lib/remix-app/actions/modals.ts @@ -23,7 +23,7 @@ export const enum modalActionTypes { type ModalPayload = { [modalActionTypes.setModal]: AppModal [modalActionTypes.handleHideModal]: any - [modalActionTypes.setToast]: { message: string | JSX.Element, timestamp: number } + [modalActionTypes.setToast]: { message: string | JSX.Element, timestamp: number, timeout?: number } [modalActionTypes.handleToaster]: any, [modalActionTypes.processQueue]: any, [modalActionTypes.setTemplateExplorer]: GenericModal diff --git a/libs/remix-ui/app/src/lib/remix-app/components/modals/dialogs.tsx b/libs/remix-ui/app/src/lib/remix-app/components/modals/dialogs.tsx index da5cbd8a72c..c8e14cdf6f0 100644 --- a/libs/remix-ui/app/src/lib/remix-app/components/modals/dialogs.tsx +++ b/libs/remix-ui/app/src/lib/remix-app/components/modals/dialogs.tsx @@ -1,16 +1,27 @@ -import React from 'react' +import React, { useMemo } from 'react' import { useDialogDispatchers, useDialogs } from '../../context/provider' -import { Toaster } from '@remix-ui/toaster' +import { ToasterContainer } from '@remix-ui/toaster' import ModalWrapper from './modal-wrapper' const AppDialogs = () => { const { handleHideModal, handleToaster } = useDialogDispatchers() - const { focusModal, focusToaster } = useDialogs() + const { focusModal, toasters } = useDialogs() + + // Map toasters to ToasterProps format with useMemo to prevent recreating on every render + const toastList = useMemo(() => { + return toasters.map((toaster) => ({ + message: toaster.message, + id: toaster.toastId || `toast-${toaster.timestamp}`, + timeout: toaster.timeout, + timestamp: toaster.timestamp, + handleHide: handleToaster + })) + }, [toasters, handleToaster]) return ( <> - + ) } diff --git a/libs/remix-ui/app/src/lib/remix-app/context/context.tsx b/libs/remix-ui/app/src/lib/remix-app/context/context.tsx index f7ef62c987b..4037f10f3c6 100644 --- a/libs/remix-ui/app/src/lib/remix-app/context/context.tsx +++ b/libs/remix-ui/app/src/lib/remix-app/context/context.tsx @@ -26,7 +26,7 @@ export const platformContext = React.createContext(null) export interface dispatchModalInterface { modal: (data: AppModal) => void - toast: (message: string | JSX.Element) => void + toast: (message: string | JSX.Element, timeout?: number, toastId?: number) => void alert: (data: AlertModal) => void handleHideModal: () => void handleToaster: () => void @@ -34,7 +34,7 @@ export interface dispatchModalInterface { export const dispatchModalContext = React.createContext({ modal: (data: AppModal) => {}, - toast: (message: string | JSX.Element) => {}, + toast: (message: string | JSX.Element, timeout?: number, toastId?: number) => {}, alert: (data: AlertModal) => {}, handleHideModal: () => {}, handleToaster: () => {} diff --git a/libs/remix-ui/app/src/lib/remix-app/context/provider.tsx b/libs/remix-ui/app/src/lib/remix-app/context/provider.tsx index 4d68896c30f..3fe7ed7760d 100644 --- a/libs/remix-ui/app/src/lib/remix-app/context/provider.tsx +++ b/libs/remix-ui/app/src/lib/remix-app/context/provider.tsx @@ -69,10 +69,11 @@ export const ModalProvider = ({ children = [], reducer = modalReducer, initialSt }) } - const toast = (message: string | JSX.Element) => { + const toast = (message: string | JSX.Element, timeout?: number, timestamp?: number) => { + timestamp = timestamp || Date.now() dispatch({ type: modalActionTypes.setToast, - payload: { message, timestamp: Date.now() } + payload: { message, timestamp, timeout } }) } diff --git a/libs/remix-ui/app/src/lib/remix-app/interface/index.ts b/libs/remix-ui/app/src/lib/remix-app/interface/index.ts index 72d8643f219..5429226e8d1 100644 --- a/libs/remix-ui/app/src/lib/remix-app/interface/index.ts +++ b/libs/remix-ui/app/src/lib/remix-app/interface/index.ts @@ -40,9 +40,9 @@ export interface AlertModal { export interface ModalState { modals: AppModal[], - toasters: {message: (string | JSX.Element), timestamp: number }[], + toasters: {message: (string | JSX.Element), timestamp: number, timeout?: number, toastId?: number }[], focusModal: AppModal, - focusToaster: {message: (string | JSX.Element), timestamp: number }, + focusToaster: {message: (string | JSX.Element), timestamp: number, timeout?: number, toastId?: number } focusTemplateExplorer: GenericModal } diff --git a/libs/remix-ui/app/src/lib/remix-app/state/modals.ts b/libs/remix-ui/app/src/lib/remix-app/state/modals.ts index 41189f69e88..f398b7b3123 100644 --- a/libs/remix-ui/app/src/lib/remix-app/state/modals.ts +++ b/libs/remix-ui/app/src/lib/remix-app/state/modals.ts @@ -17,7 +17,7 @@ export const ModalInitialState: ModalState = { cancelFn: () => { }, showCancelIcon: false }, - focusToaster: { message: '', timestamp: 0 }, + focusToaster: { message: '', timestamp: 0, timeout: 2000 }, focusTemplateExplorer: { id: '', hide: true, diff --git a/libs/remix-ui/toaster/README.md b/libs/remix-ui/toaster/README.md new file mode 100644 index 00000000000..cdb3ce2729d --- /dev/null +++ b/libs/remix-ui/toaster/README.md @@ -0,0 +1,147 @@ +# Toaster Plugin Usage Guide + +This guide explains how to use the Remix Toaster notification system in your plugin. + +## Overview + +The toaster system provides a simple way to display temporary notification messages to users. It's accessible through the `notification` plugin API. + +## Basic Usage + +### Displaying a Toast + +To display a simple toast notification: + +```typescript +const id = await remix.call('notification' as any, 'toast', 'Your message here') +``` + +The `toast` method returns a unique ID (timestamp) that can be used to dismiss the toast later. + +### Displaying a Toast with Custom Timeout + +By default, toasts disappear after 2000ms (2 seconds). You can specify a custom duration: + +```typescript +// Show toast for 10 seconds +const id = await remix.call('notification' as any, 'toast', 'This message will stay longer', 10000) +``` + +**Timeout Behavior:** +- **Default:** 2000ms (2 seconds) +- **> 2000ms:** Displays a loading spinner icon +- **> 5000ms:** Displays both a loading spinner icon and a close button + +### Hiding a Toast Manually + +You can dismiss a toast before its timeout expires using the ID returned from the `toast` call: + +```typescript +const id = await remix.call('notification' as any, 'toast', 'Processing...') + +// Do some work... +await doSomeWork() + +// Hide the toast when done +await remix.call('notification' as any, 'hideToaster', id) +``` + +## API Reference + +### `toast(message: string | JSX.Element, timeout?: number, timestamp?: number): Promise` + +Displays a toast notification. + +**Parameters:** +- `message` - The message to display (string or JSX element) +- `timeout` (optional) - Duration in milliseconds before the toast auto-dismisses (default: 2000) +- `timestamp` (optional) - Custom ID for the toast (auto-generated if not provided) + +**Returns:** A unique ID (number) that can be used to dismiss the toast + +### `hideToaster(id: number): Promise` + +Manually dismisses a specific toast notification. + +**Parameters:** +- `id` - The toast ID returned by the `toast` method + +## Examples + +### Example 1: Simple Notification + +```typescript +await remix.call('notification' as any, 'toast', 'File saved successfully!') +``` + +### Example 2: Long-Running Operation + +```typescript +// Show a persistent toast with spinner +const id = await remix.call('notification' as any, 'toast', 'Compiling contracts...', 30000) + +try { + await compileContracts() + // Hide the toast when done + await remix.call('notification' as any, 'hideToaster', id) + // Show success message + await remix.call('notification' as any, 'toast', 'Compilation completed!') +} catch (error) { + await remix.call('notification' as any, 'hideToaster', id) + await remix.call('notification' as any, 'toast', 'Compilation failed!') +} +``` + +### Example 3: Multiple Sequential Toasts + +```typescript +const id1 = await remix.call('notification' as any, 'toast', 'Step 1: Initializing...') +await step1() + +const id2 = await remix.call('notification' as any, 'toast', 'Step 2: Processing...') +await step2() + +const id3 = await remix.call('notification' as any, 'toast', 'Step 3: Finalizing...') +await step3() + +await remix.call('notification' as any, 'toast', 'All steps completed!') +``` + +## Best Practices + +1. **Keep messages concise** - Toast notifications should be brief and to the point +2. **Use appropriate timeouts** - Short messages (< 10 words) can use the default timeout, longer messages should have extended timeouts +3. **Clean up long-running toasts** - Always hide toasts for long-running operations once they complete +4. **Provide feedback** - Use toasts to confirm user actions (saves, deletions, etc.) +5. **Don't overuse** - Too many toasts can be overwhelming; use them for important notifications only + +## UI Features + +- **Position:** Top-right corner of the screen +- **Styling:** Uses Bootstrap alert classes (`alert-info`) +- **Loading indicator:** Automatically shown for toasts with timeout > 2000ms +- **Close button:** Automatically shown for toasts with timeout > 5000ms +- **Auto-dismiss:** Toasts automatically disappear after the specified timeout +- **Manual dismiss:** Toasts can be dismissed early using `hideToaster` + +## TypeScript Types + +```typescript +interface ToasterProps { + message: string | JSX.Element + timeout?: number + handleHide?: () => void + timestamp?: number + id?: string | number + onToastCreated?: (toastId: string | number) => void +} +``` + +## Related APIs + +The notification plugin also provides other methods for user interaction: + +- `modal()` - Display a modal dialog +- `alert()` - Display an alert dialog + +For more information, see the notification plugin documentation. diff --git a/libs/remix-ui/toaster/src/lib/toaster.css b/libs/remix-ui/toaster/src/lib/toaster.css index c026edbf492..24293af2c3d 100644 --- a/libs/remix-ui/toaster/src/lib/toaster.css +++ b/libs/remix-ui/toaster/src/lib/toaster.css @@ -1,43 +1,206 @@ -.remixui_tooltip { - z-index: 1001; - display: flex; - justify-content: space-between; - align-items: center; - position: fixed; - min-height: 50px; - padding: 16px 24px 12px; - border-radius: 3px; - left: 40%; - font-size: 14px; - text-align: center; - bottom: -0px; - flex-direction: row; -} -@-webkit-keyframes remixui_animatebottom { - 0% {bottom: -300px} - 100% {bottom: 0px} -} -@keyframes remixui_animatebottom { - 0% {bottom: -300px} - 100% {bottom: 0px} -} -@-webkit-keyframes remixui_animatetop { - 0% {bottom: 0px} - 100% {bottom: -300px} -} -@keyframes remixui_animatetop { - 0% {bottom: 0px} - 100% {bottom: -300px} -} -.remixui_animateTop { - -webkit-animation-name: remixui_animatetop; - -webkit-animation-duration: 2s; - animation-name: remixui_animatetop; - animation-duration: 2s; -} -.remixui_animateBottom { - -webkit-animation-name: remixui_animatebottom; - -webkit-animation-duration: 2s; - animation-name: remixui_animatebottom; - animation-duration: 2s; +/* Sonner toast styling */ +.remixui_sonner_toast { + border-radius: 8px; + padding: 5px 5px 5px 5px; + font-weight: 700 !important; + font-size: 19px !important; + min-width: 350px; + max-width: 600px; + word-wrap: break-word; + overflow-wrap: break-word; + white-space: normal; + line-height: 1.4; } + +.remixui_custom_toast { + display: flex; + align-items: center; + gap: 12px; +} + +/* Override Sonner default styles to match Remix theme */ +[data-sonner-toaster] { + z-index: 1001 !important; + display: flex; + flex-direction: column; + transform: none !important; + --normal-bg: transparent !important; + --normal-border: transparent !important; +} + +[data-sonner-toaster]:hover { + transform: none !important; +} + +[data-sonner-toaster]:hover [data-sonner-toast] { + transform: none !important; + scale: 1 !important; + opacity: 1 !important; +} + +[data-sonner-toaster][data-y-position="top"] { + top: 0; +} + +[data-sonner-toaster][data-x-position="right"] { + right: 0; +} + +[data-sonner-toast] { + color: var(--bs-dark, #212529) !important; + border: 0px solid var(--bs-dark, #6c757d) !important; + font-weight: 700 !important; + font-size: 19px !important; + word-wrap: break-word; + overflow-wrap: break-word; + opacity: 1 !important; + visibility: visible !important; + pointer-events: auto !important; + position: relative !important; + width: 100%; + transform: none !important; + transition: none !important; + animation: none !important; +} + +[data-sonner-toast]:hover { + transform: none !important; + scale: 1 !important; + transition: none !important; +} + +[data-sonner-toast][data-expanded="true"] { + transform: none !important; + scale: 1 !important; + transition: none !important; +} + +[data-sonner-toast][data-expanded="false"] { + transform: none !important; + scale: 1 !important; + transition: none !important; +} + +[data-sonner-toast][data-front="true"] { + transform: none !important; + scale: 1 !important; + transition: none !important; +} + +[data-sonner-toast][data-front="false"] { + transform: none !important; + scale: 1 !important; + opacity: 1 !important; + transition: none !important; +} + +[data-sonner-toast][data-index]:not([data-index="0"]) { + transform: none !important; + scale: 1 !important; + opacity: 1 !important; + transition: none !important; +} + +[data-sonner-toast][data-styled="true"] { + padding: 20px 28px !important; + gap: 12px !important; + font-weight: 700 !important; + transform: none !important; +} + +/* Dark theme support */ +[data-sonner-toast][data-theme="dark"], +.dark [data-sonner-toast] { + background: var(--bs-dark, #212529) !important; + color: var(--bs-light, #f8f9fa) !important; + border: 0px solid var(--bs-secondary, #6c757d) !important; + font-weight: 700 !important; + transform: none !important; +} + +/* Close button styling */ +[data-sonner-toast] [data-close-button] { + position: absolute; + top: 12px; + right: 12px; + background: transparent !important; + border: none; + color: var(--bs-dark, #212529) !important; + opacity: 0.7; + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.2s ease; + width: 24px; + height: 24px; + z-index: 1; +} + +[data-sonner-toast] [data-close-button]:hover { + opacity: 1; + background: rgba(0, 0, 0, 0.05) !important; +} + +[data-sonner-toast] [data-close-button] svg { + width: 16px; + height: 16px; +} + +/* Dark theme close button */ +[data-sonner-toast][data-theme="dark"] [data-close-button], +.dark [data-sonner-toast] [data-close-button] { + color: var(--bs-light, #f8f9fa) !important; +} + +[data-sonner-toast][data-theme="dark"] [data-close-button]:hover, +.dark [data-sonner-toast] [data-close-button]:hover { + background: rgba(255, 255, 255, 0.1) !important; +} + +/* Custom codicon close button styling */ +[data-sonner-toast] .codicon-close { + position: absolute; + top: 12px; + right: 12px; + background: transparent !important; + border: none; + color: var(--bs-dark, #212529) !important; + opacity: 0.7; + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.2s ease; + width: 24px; + height: 24px; + z-index: 1; +} + +[data-sonner-toast] .codicon-close:hover { + opacity: 1; + background: rgba(0, 0, 0, 0.05) !important; +} + +/* Dark theme codicon close button */ +[data-sonner-toast][data-theme="dark"] .codicon-close, +.dark [data-sonner-toast] .codicon-close { + color: var(--bs-light, #f8f9fa) !important; +} + +[data-sonner-toast][data-theme="dark"] .codicon-close:hover, +.dark [data-sonner-toast] .codicon-close:hover { + background: rgba(255, 255, 255, 0.1) !important; +} + +[data-sonner-toast][data-expanded=false][data-front=false] { + height: auto !important; +} + +[data-sonner-toast][data-mounted=true][data-expanded=true] { + height: auto !important; +} \ No newline at end of file diff --git a/libs/remix-ui/toaster/src/lib/toaster.tsx b/libs/remix-ui/toaster/src/lib/toaster.tsx index 0870f4515db..6440b61f069 100644 --- a/libs/remix-ui/toaster/src/lib/toaster.tsx +++ b/libs/remix-ui/toaster/src/lib/toaster.tsx @@ -1,155 +1,236 @@ -import React, {useEffect, useState} from 'react' // eslint-disable-line -import {ModalDialog} from '@remix-ui/modal-dialog' // eslint-disable-line +import React, {useEffect} from 'react' // eslint-disable-line +import { Toaster as SonnerToaster, toast } from 'sonner' import './toaster.css' +// Export toast so callers can use toast.dismiss(id) +export { toast } + /* eslint-disable-next-line */ export interface ToasterProps { message: string | JSX.Element - timeOut?: number + timeout?: number handleHide?: () => void timestamp?: number + id?: string | number + onToastCreated?: (toastId: string | number) => void } -export const Toaster = (props: ToasterProps) => { - const [state, setState] = useState<{ - message: string | JSX.Element - hide: boolean - hiding: boolean - timeOutId: any - timeOut: number - showModal: boolean - showFullBtn: boolean - }>({ - message: '', - hide: true, - hiding: false, - timeOutId: null, - timeOut: props.timeOut || 7000, - showModal: false, - showFullBtn: false - }) +export interface ToasterContainerProps { + toasts: ToasterProps[] +} + +// Individual toast trigger component (no UI, just triggers toast) +export const ToastTrigger = (props: ToasterProps) => { + const mountedRef = React.useRef(false) useEffect(() => { - if (props.message) { - const timeOutId = setTimeout(() => { - setState((prevState) => { - return { ...prevState, hiding: true } - }) - }, state.timeOut) - - setState((prevState) => { - if (typeof props.message === 'string' && props.message.length > 201) { - const shortTooltipText = props.message.substring(0, 200) + '...' - - return { - ...prevState, - hide: false, - hiding: false, - timeOutId, - message: shortTooltipText + // Only trigger on mount, not on updates + if (!mountedRef.current && props.message && props.id) { + mountedRef.current = true + + // Show toast using Sonner - Sonner handles deduplication via ID automatically + const duration = props.timeout || 2000 + const showCloseButton = true + const showLoadingIcon = duration > 2000 + + if (typeof props.message === 'string') { + const toastId = toast.custom( + () => ( +
+
+ {showLoadingIcon && ( + + Loading... + + )} + Remix + {showCloseButton && ( + + )} +
+
+ {props.message} +
+
+ ), + { + id: props.id, + unstyled: true, + duration, + closeButton: false, + onDismiss: () => { + props.handleHide && props.handleHide() + }, + onAutoClose: () => { + props.handleHide && props.handleHide() + } } - } else { - const shortTooltipText = props.message - - return { - ...prevState, - hide: false, - hiding: false, - timeOutId, - message: shortTooltipText + ) + // Call the callback with the toast ID so caller can dismiss it later + if (props.onToastCreated) { + props.onToastCreated(toastId) + } + } else { + // For JSX elements, use toast.custom + const toastId = toast.custom( + () => ( +
+
+ {showLoadingIcon && ( + + Loading... + + )} + Remix + {showCloseButton && ( + + )} +
+
+ {props.message} +
+
+ ), + { + id: props.id, + duration, + closeButton: false, + onDismiss: () => { + props.handleHide && props.handleHide() + }, + onAutoClose: () => { + props.handleHide && props.handleHide() + } } + ) + // Call the callback with the toast ID so caller can dismiss it later + if (props.onToastCreated) { + props.onToastCreated(toastId) } - }) - } - }, [props.message, props.timestamp]) - - useEffect(() => { - if (state.hiding) { - setTimeout(() => { - closeTheToaster() - }, 1800) - } - }, [state.hiding]) - - const showFullMessage = () => { - setState((prevState) => { - return { ...prevState, showModal: true } - }) - } - - const hideFullMessage = () => { - //eslint-disable-line - setState((prevState) => { - return { ...prevState, showModal: false } - }) - } - - const closeTheToaster = () => { - if (state.timeOutId) { - clearTimeout(state.timeOutId) - } - props.handleHide && props.handleHide() - setState((prevState) => { - return { - ...prevState, - message: '', - hide: true, - hiding: false, - timeOutId: null, - showModal: false } - }) - } - - const handleMouseEnter = () => { - if (state.timeOutId) { - clearTimeout(state.timeOutId) - } - setState((prevState) => { - return { ...prevState, timeOutId: null } - }) - } - - const handleMouseLeave = () => { - if (!state.timeOutId) { - const timeOutId = setTimeout(() => { - setState((prevState) => { - return { ...prevState, hiding: true } - }) - }, state.timeOut) - - setState((prevState) => { - return { ...prevState, timeOutId } - }) } - } + }, []) + + return null +} +// Container component that renders the Sonner toaster and all toast triggers +export const ToasterContainer = (props: ToasterContainerProps) => { return ( <> - {}} hide={!state.showModal} handleHide={hideFullMessage} /> - {!state.hide && ( -
- - {state.message} - {state.showFullBtn && ( - - )} - - - - -
- )} + + {props.toasts.map((toastProps) => ( + + ))} ) } +// Legacy component for backward compatibility +export const Toaster = (props: ToasterProps) => { + useEffect(() => { + if (props.message) { + // Show toast using Sonner + const duration = props.timeout || 2000 + const showCloseButton = true + const showLoadingIcon = duration > 2000 + + let toastId: string | number + + if (typeof props.message === 'string') { + + toastId = toast.custom( + () => ( +
+
+ {showLoadingIcon && ( + + Loading... + + )} + Remix + {showCloseButton && ( + + )} +
+
+ {props.message} +
+
+ ), + { + id: props.id, + unstyled: true, + duration, + closeButton: false, + onDismiss: () => { + props.handleHide && props.handleHide() + }, + onAutoClose: () => { + props.handleHide && props.handleHide() + } + } + ) + } else { + // For JSX elements, use toast.custom + toastId = toast.custom( + () => ( +
+
+ {showLoadingIcon && ( + + Loading... + + )} + Remix + {showCloseButton && ( + + )} +
+
+ {props.message} +
+
+ ), + { + id: props.id, + duration, + closeButton: false, + onDismiss: () => { + props.handleHide && props.handleHide() + }, + onAutoClose: () => { + props.handleHide && props.handleHide() + } + } + ) + } + + // Call the callback with the toast ID so caller can dismiss it later + if (props.onToastCreated) { + props.onToastCreated(toastId) + } + } + }, [props.message, props.timestamp]) + + return
+} + export default Toaster diff --git a/package.json b/package.json index 7d1563ffa6a..1409edf2fd7 100644 --- a/package.json +++ b/package.json @@ -238,6 +238,7 @@ "sol2uml": "^2.4.3", "solhint": "^3.4.1", "solidity-comments-extractor": "^0.0.8", + "sonner": "^2.0.7", "string-similarity": "^4.0.4", "svg2pdf.js": "^2.2.1", "text-encoding": "^0.7.0", diff --git a/yarn.lock b/yarn.lock index fc02d116a65..96215653679 100644 --- a/yarn.lock +++ b/yarn.lock @@ -29354,6 +29354,11 @@ sonic-boom@^2.2.1: dependencies: atomic-sleep "^1.0.0" +sonner@^2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/sonner/-/sonner-2.0.7.tgz#810c1487a67ec3370126e0f400dfb9edddc3e4f6" + integrity sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w== + sort-keys@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-2.0.0.tgz#658535584861ec97d730d6cf41822e1f56684128"