From e168abc4d834bef226b0e3315b40b9919cb1d4df Mon Sep 17 00:00:00 2001 From: myusername Date: Wed, 13 May 2026 15:17:03 +0530 Subject: [PATCH] feat: Toast component created --- .storybook/{preview.ts => preview.tsx} | 21 +++- README.md | 2 +- package.json | 1 + pnpm-lock.yaml | 15 +++ src/hooks/useToast.ts | 5 + src/layouts/MainLayout/BaseLayout.tsx | 2 + .../composites/AvatarMenu/AvatarMenu.tsx | 11 +- .../integrations/Toast/Toast.stories.tsx | 116 ++++++++++++++++++ .../integrations/Toast/ToastProvider.tsx | 22 ++++ src/shared/integrations/Toast/index.ts | 3 + src/shared/integrations/Toast/toast.css | 51 ++++++++ src/shared/integrations/Toast/toast.tsx | 62 ++++++++++ src/shared/primitives/Switch/Switch.tsx | 2 + src/shared/theme/ThemeProvider.tsx | 91 +++++--------- src/shared/theme/index.ts | 11 +- 15 files changed, 344 insertions(+), 71 deletions(-) rename .storybook/{preview.ts => preview.tsx} (50%) create mode 100644 src/hooks/useToast.ts create mode 100644 src/shared/integrations/Toast/Toast.stories.tsx create mode 100644 src/shared/integrations/Toast/ToastProvider.tsx create mode 100644 src/shared/integrations/Toast/index.ts create mode 100644 src/shared/integrations/Toast/toast.css create mode 100644 src/shared/integrations/Toast/toast.tsx diff --git a/.storybook/preview.ts b/.storybook/preview.tsx similarity index 50% rename from .storybook/preview.ts rename to .storybook/preview.tsx index 241d8e8..315895f 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.tsx @@ -1,19 +1,32 @@ import type { Preview } from '@storybook/react-vite' -import '../src/index.css' + +import '@/index.css' + +import { ToastProvider } from '../src/shared/integrations/Toast' + +import { ThemeProvider } from '../src/shared/theme' const preview: Preview = { + decorators: [ + (Story) => ( + + + + + + ), + ], + parameters: { controls: { matchers: { color: /(background|color)$/i, + date: /Date$/i, }, }, a11y: { - // 'todo' - show a11y violations in the test UI only - // 'error' - fail CI on a11y violations - // 'off' - skip a11y checks entirely test: 'todo', }, }, diff --git a/README.md b/README.md index 933045f..d3b9f8b 100644 --- a/README.md +++ b/README.md @@ -72,4 +72,4 @@ export default defineConfig([ ]) ``` - + diff --git a/package.json b/package.json index e56ea6e..7b7499e 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "react-dom": "^19.2.5", "react-icons": "^5.5.0", "react-router-dom": "^7.7.1", + "react-toastify": "^11.1.0", "styled-components": "^6.2.0", "tailwindcss": "^4.2.4", "vite-plugin-remove-console": "^2.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b9e230..ac6a7a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: react-router-dom: specifier: ^7.7.1 version: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-toastify: + specifier: ^11.1.0 + version: 11.1.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) styled-components: specifier: ^6.2.0 version: 6.4.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -2460,6 +2463,12 @@ packages: react-dom: optional: true + react-toastify@11.1.0: + resolution: {integrity: sha512-e9h23x3phN0wbFeB6yovmWp7lobzV4CaCH0LO8nVP6H7Y+3GbcLpIzMm9dJhcp1RXbpyfvjgpfXqO80QAmn7sg==} + peerDependencies: + react: ^18 || ^19 + react-dom: ^18 || ^19 + react@19.2.6: resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} engines: {node: '>=0.10.0'} @@ -5269,6 +5278,12 @@ snapshots: optionalDependencies: react-dom: 19.2.6(react@19.2.6) + react-toastify@11.1.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + clsx: 2.1.1 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react@19.2.6: {} recast@0.23.11: diff --git a/src/hooks/useToast.ts b/src/hooks/useToast.ts new file mode 100644 index 0000000..ae7a98c --- /dev/null +++ b/src/hooks/useToast.ts @@ -0,0 +1,5 @@ +import { toast } from '@/shared/integrations/Toast/toast.tsx' + +export const useToast = () => { + return toast +} diff --git a/src/layouts/MainLayout/BaseLayout.tsx b/src/layouts/MainLayout/BaseLayout.tsx index 2e0c20d..83dfb3b 100644 --- a/src/layouts/MainLayout/BaseLayout.tsx +++ b/src/layouts/MainLayout/BaseLayout.tsx @@ -1,4 +1,5 @@ import AvatarMenu from '@/shared/composites/AvatarMenu/AvatarMenu' +import { ToastProvider } from '@/shared/integrations/Toast' import { Tooltip } from '@/shared/primitives/Tooltip' // import ThemeToggleSwitch from '@/ThemeToggleSwitch' import React, { Suspense } from 'react' @@ -8,6 +9,7 @@ const BaseLayout: React.FC = () => { return (
{/* Main Content */} +
diff --git a/src/shared/composites/AvatarMenu/AvatarMenu.tsx b/src/shared/composites/AvatarMenu/AvatarMenu.tsx index 46f04f5..150db77 100644 --- a/src/shared/composites/AvatarMenu/AvatarMenu.tsx +++ b/src/shared/composites/AvatarMenu/AvatarMenu.tsx @@ -1,5 +1,4 @@ import { useEffect, useRef, useState } from 'react' - import { Avatar } from '@/shared/primitives/Avatar' import { Switch } from '@/shared/primitives/Switch/Switch' import { @@ -15,6 +14,8 @@ import { } from '@heroicons/react/24/outline' import { useTheme } from '@/shared/theme' import { Button } from '@/shared/primitives/Button' +import { useToast } from '@/hooks/useToast' + export interface AvatarMenuProps { name: string @@ -41,6 +42,7 @@ export function AvatarMenu({ const [open, setOpen] = useState(false) const { themeName, setTheme } = useTheme() const ref = useRef(null) + const toast = useToast() useEffect(() => { const handleClickOutside = (e: MouseEvent) => { @@ -114,7 +116,11 @@ export function AvatarMenu({ className="ms-6" checked={themeName === 'dark'} onChange={(checked: boolean) => { - return setTheme(checked ? 'dark' : 'light') + const newTheme = checked ? 'dark' : 'light' + + setTheme(newTheme) + + toast.info(`Theme changed to ${newTheme}`) }} /> } @@ -141,7 +147,6 @@ export function AvatarMenu({ variant={'danger'} leftIcon={} > - {/*
*/}
Logout
diff --git a/src/shared/integrations/Toast/Toast.stories.tsx b/src/shared/integrations/Toast/Toast.stories.tsx new file mode 100644 index 0000000..4cabe80 --- /dev/null +++ b/src/shared/integrations/Toast/Toast.stories.tsx @@ -0,0 +1,116 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' + +import { toast } from './toast' + +import { Button } from '@/shared/primitives/Button' + +const meta = { + title: 'Shared/Integrations/Toast', + + tags: ['autodocs'], +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Success: Story = { + render: () => ( + <> + + + ), +} + +export const Error: Story = { + render: () => ( + <> + + + ), +} + +export const Warning: Story = { + render: () => ( + <> + + + ), +} + +export const Info: Story = { + render: () => ( + <> + + + ), +} + +export const WithAction: Story = { + render: () => ( + <> + + + ), +} + +export const StackedQueue: Story = { + render: () => ( + <> + + + ), +} diff --git a/src/shared/integrations/Toast/ToastProvider.tsx b/src/shared/integrations/Toast/ToastProvider.tsx new file mode 100644 index 0000000..20ab4c4 --- /dev/null +++ b/src/shared/integrations/Toast/ToastProvider.tsx @@ -0,0 +1,22 @@ +import { ToastContainer, Slide } from 'react-toastify' + +import 'react-toastify/dist/ReactToastify.css' + +import './toast.css' + +export function ToastProvider() { + return ( + + ) +} + +export default ToastProvider diff --git a/src/shared/integrations/Toast/index.ts b/src/shared/integrations/Toast/index.ts new file mode 100644 index 0000000..8c2ae1d --- /dev/null +++ b/src/shared/integrations/Toast/index.ts @@ -0,0 +1,3 @@ +export { ToastProvider } from './ToastProvider' + +export { toast } from './toast' diff --git a/src/shared/integrations/Toast/toast.css b/src/shared/integrations/Toast/toast.css new file mode 100644 index 0000000..c23552b --- /dev/null +++ b/src/shared/integrations/Toast/toast.css @@ -0,0 +1,51 @@ +.Toastify__toast { + background: var(--color-bg-secondary); + + color: var(--color-text-primary); + + border: 1px solid var(--color-border-secondary); + + border-radius: var(--radius-md); + + box-shadow: var(--shadow-md); + + min-height: 56px; + + backdrop-filter: blur(8px); +} + +.Toastify__toast-body { + color: var(--color-text-primary); +} + +.Toastify__close-button { + color: var(--color-text-secondary); + + opacity: 1; +} + +/* Remove default colored backgrounds */ +.Toastify__toast--success, +.Toastify__toast--error, +.Toastify__toast--warning, +.Toastify__toast--info { + background: var(--color-bg-secondary); +} + +/* Progress bars ONLY use semantic colors */ + +.Toastify__progress-bar--success { + background: var(--color-text-success); +} + +.Toastify__progress-bar--error { + background: var(--color-text-danger); +} + +.Toastify__progress-bar--warning { + background: var(--color-text-warning); +} + +.Toastify__progress-bar--info { + background: var(--color-text-info); +} diff --git a/src/shared/integrations/Toast/toast.tsx b/src/shared/integrations/Toast/toast.tsx new file mode 100644 index 0000000..9c29a65 --- /dev/null +++ b/src/shared/integrations/Toast/toast.tsx @@ -0,0 +1,62 @@ +import { toast as reactToast } from 'react-toastify' + +import { + CheckCircleIcon, + ExclamationTriangleIcon, + XCircleIcon, + InformationCircleIcon, +} from '@heroicons/react/24/solid' + +export interface ToastAction { + label: string + + onClick: () => void +} + +export interface ToastOptions { + duration?: number + + action?: ToastAction +} + +type ToastVariant = 'success' | 'error' | 'warning' | 'info' + +const icons = { + success: , + + error: , + + warning: , + + info: , +} + +const buildContent = (message: string, action?: ToastAction) => ( +
+ {message} + + {action && ( + + )} +
+) + +const createToast = (variant: ToastVariant, message: string, options?: ToastOptions) => { + return reactToast[variant](buildContent(message, options?.action), { + autoClose: options?.duration ?? 2500, + + icon: icons[variant], + }) +} + +export const toast = { + success: (message: string, options?: ToastOptions) => createToast('success', message, options), + + error: (message: string, options?: ToastOptions) => createToast('error', message, options), + + warning: (message: string, options?: ToastOptions) => createToast('warning', message, options), + + info: (message: string, options?: ToastOptions) => createToast('info', message, options), +} diff --git a/src/shared/primitives/Switch/Switch.tsx b/src/shared/primitives/Switch/Switch.tsx index 2333515..3fcdbd4 100644 --- a/src/shared/primitives/Switch/Switch.tsx +++ b/src/shared/primitives/Switch/Switch.tsx @@ -8,6 +8,8 @@ export interface SwitchProps { disabled?: boolean className?: string + + onClick?: () => void } const switchTrack = cva( diff --git a/src/shared/theme/ThemeProvider.tsx b/src/shared/theme/ThemeProvider.tsx index 5733606..b75baf3 100644 --- a/src/shared/theme/ThemeProvider.tsx +++ b/src/shared/theme/ThemeProvider.tsx @@ -1,58 +1,33 @@ -import { useEffect } from 'react'; -import { themeRegistry } from './themeRegistry'; -import { useThemeStore } from './themeStore'; - -const applyTokens = ( - tokens: object, - prefix = '' -) => { - const root = document.documentElement; - - Object.entries(tokens).forEach(([key, value]) => { - const newPrefix = prefix - ? `${prefix}-${key}` - : key; - - if ( - typeof value === 'object' && - value !== null - ) { - applyTokens(value, newPrefix); - } else if (typeof value === 'string') { - root.style.setProperty( - `--${newPrefix}`, - value - ); - } - }); -}; - -export const ThemeProvider = ({ - children, -}: { - children: React.ReactNode; -}) => { - const themeName = useThemeStore( - (s) => s.themeName - ); - - useEffect(() => { - const resolvedTheme = - themeRegistry[themeName] ?? - themeRegistry.light; - - document.documentElement.setAttribute( - 'data-theme', - resolvedTheme.name - ); - - applyTokens(resolvedTheme.tokens); - - document.documentElement.setAttribute( - 'data-theme-loaded', - 'true' - ); - }, [themeName]); - - return children; -}; \ No newline at end of file +import { useLayoutEffect } from 'react' +import { themeRegistry } from './themeRegistry' +import { useThemeStore } from './themeStore' + +const applyTokens = (tokens: object, prefix = '') => { + const root = document.documentElement + + Object.entries(tokens).forEach(([key, value]) => { + const newPrefix = prefix ? `${prefix}-${key}` : key + + if (typeof value === 'object' && value !== null) { + applyTokens(value, newPrefix) + } else if (typeof value === 'string') { + root.style.setProperty(`--${newPrefix}`, value) + } + }) +} + +export const ThemeProvider = ({ children }: { children: React.ReactNode }) => { + const themeName = useThemeStore((s) => s.themeName) + + useLayoutEffect(() => { + const resolvedTheme = themeRegistry[themeName] ?? themeRegistry.light + + document.documentElement.setAttribute('data-theme', resolvedTheme.name) + + applyTokens(resolvedTheme.tokens) + + document.documentElement.setAttribute('data-theme-loaded', 'true') + }, [themeName]) + + return children +} diff --git a/src/shared/theme/index.ts b/src/shared/theme/index.ts index 5febb6c..12e8378 100644 --- a/src/shared/theme/index.ts +++ b/src/shared/theme/index.ts @@ -1,5 +1,6 @@ -export * from './types'; -export * from './themeRegistry'; -export * from './themeStore'; -export * from './useTheme'; -export * from './ThemePicker'; +export * from './types' +export * from './themeRegistry' +export * from './themeStore' +export * from './useTheme' +export * from './ThemePicker' +export * from './ThemeProvider'