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'