(
...rest
},
ref
- ) => (
-
- {children}
-
- )
+ ) => {
+ const textClassName = textVariants({
+ size,
+ className,
+ weight,
+ variant,
+ transform,
+ align,
+ lineClamp,
+ underline,
+ strikeThrough,
+ italic
+ });
+
+ return (
+
+ {children}
+
+ );
+ }
);
Text.displayName = 'Text';
diff --git a/packages/raystack/components/theme-provider/__tests__/theme-provider.test.tsx b/packages/raystack/components/theme-provider/__tests__/theme-provider.test.tsx
new file mode 100644
index 000000000..d794b1f0d
--- /dev/null
+++ b/packages/raystack/components/theme-provider/__tests__/theme-provider.test.tsx
@@ -0,0 +1,387 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { ThemeSwitcher } from '../switcher';
+import { ThemeProvider, useTheme } from '../theme';
+
+// Mock localStorage
+const localStorageMock = {
+ getItem: vi.fn(),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+ clear: vi.fn()
+};
+
+Object.defineProperty(window, 'localStorage', {
+ value: localStorageMock
+});
+
+// Mock matchMedia
+Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation(query => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn()
+ }))
+});
+
+describe('ThemeProvider', () => {
+ beforeEach(() => {
+ localStorageMock.getItem.mockClear();
+ localStorageMock.setItem.mockClear();
+ document.documentElement.removeAttribute('data-theme');
+ document.documentElement.removeAttribute('data-style');
+ document.documentElement.removeAttribute('data-accent-color');
+ document.documentElement.removeAttribute('data-gray-color');
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('Basic Rendering', () => {
+ it('renders children correctly', () => {
+ render(
+
+ Test content
+
+ );
+
+ expect(screen.getByText('Test content')).toBeInTheDocument();
+ });
+
+ it('provides default theme context', () => {
+ const TestComponent = () => {
+ const { theme, themes } = useTheme();
+ return (
+
+ {theme}
+ {themes.join(',')}
+
+ );
+ };
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByTestId('current-theme')).toHaveTextContent('system');
+ expect(screen.getByTestId('available-themes')).toHaveTextContent(
+ 'light,dark,system'
+ );
+ });
+
+ it('applies default theme attributes to document', () => {
+ render(
+
+ Test
+
+ );
+
+ expect(document.documentElement.getAttribute('data-style')).toBe(
+ 'modern'
+ );
+ expect(document.documentElement.getAttribute('data-accent-color')).toBe(
+ 'indigo'
+ );
+ expect(document.documentElement.getAttribute('data-gray-color')).toBe(
+ 'gray'
+ );
+ });
+ });
+
+ describe('Theme Configuration', () => {
+ it('accepts custom default theme', () => {
+ const TestComponent = () => {
+ const { theme } = useTheme();
+ return {theme};
+ };
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByTestId('theme')).toHaveTextContent('dark');
+ });
+
+ it('applies custom style attributes', () => {
+ render(
+
+ Test
+
+ );
+
+ expect(document.documentElement.getAttribute('data-style')).toBe(
+ 'traditional'
+ );
+ expect(document.documentElement.getAttribute('data-accent-color')).toBe(
+ 'mint'
+ );
+ expect(document.documentElement.getAttribute('data-gray-color')).toBe(
+ 'slate'
+ );
+ });
+
+ it('handles forced theme', () => {
+ const TestComponent = () => {
+ const { theme, forcedTheme } = useTheme();
+ return (
+
+ {theme}
+ {forcedTheme}
+
+ );
+ };
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByTestId('theme')).toHaveTextContent('system');
+ expect(screen.getByTestId('forced')).toHaveTextContent('dark');
+ });
+ });
+
+ describe('useTheme Hook', () => {
+ it('provides theme context values', () => {
+ const TestComponent = () => {
+ const context = useTheme();
+ return (
+
+
+ {typeof context.setTheme === 'function' ? 'true' : 'false'}
+
+
+ {Array.isArray(context.themes) ? 'true' : 'false'}
+
+
+ );
+ };
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByTestId('has-set-theme')).toHaveTextContent('true');
+ expect(screen.getByTestId('has-themes')).toHaveTextContent('true');
+ });
+
+ it('allows theme changes', () => {
+ const TestComponent = () => {
+ const { theme, setTheme } = useTheme();
+ return (
+
+ {theme}
+
+
+ );
+ };
+
+ render(
+
+
+
+ );
+
+ const button = screen.getByText('Set Dark Theme');
+ fireEvent.click(button);
+
+ expect(localStorageMock.setItem).toHaveBeenCalledWith('theme', 'dark');
+ });
+
+ it('returns default context when used outside provider', () => {
+ const TestComponent = () => {
+ const { setTheme, themes } = useTheme();
+ return (
+
+
+ {typeof setTheme === 'function' ? 'true' : 'false'}
+
+ {themes.length}
+
+ );
+ };
+
+ render();
+
+ expect(screen.getByTestId('has-set-theme')).toHaveTextContent('true');
+ expect(screen.getByTestId('themes-length')).toHaveTextContent('0');
+ });
+ });
+
+ describe('System Theme Detection', () => {
+ it('enables system theme by default', () => {
+ const TestComponent = () => {
+ const { themes, systemTheme } = useTheme();
+ return (
+
+
+ {themes.includes('system') ? 'true' : 'false'}
+
+ {systemTheme || 'none'}
+
+ );
+ };
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByTestId('has-system')).toHaveTextContent('true');
+ });
+
+ it('can disable system theme', () => {
+ const TestComponent = () => {
+ const { themes } = useTheme();
+ return {themes.join(',')};
+ };
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByTestId('themes')).toHaveTextContent('light,dark');
+ });
+ });
+
+ describe('Local Storage Integration', () => {
+ it('reads initial theme from localStorage', () => {
+ localStorageMock.getItem.mockReturnValue('dark');
+
+ const TestComponent = () => {
+ const { theme } = useTheme();
+ return {theme};
+ };
+
+ render(
+
+
+
+ );
+
+ expect(localStorageMock.getItem).toHaveBeenCalledWith('theme');
+ });
+
+ it('uses custom storage key', () => {
+ const TestComponent = () => {
+ const { setTheme } = useTheme();
+ return ;
+ };
+
+ render(
+
+
+
+ );
+
+ const button = screen.getByText('Set Theme');
+ fireEvent.click(button);
+
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(
+ 'custom-theme',
+ 'light'
+ );
+ });
+ });
+});
+
+describe('ThemeSwitcher', () => {
+ beforeEach(() => {
+ localStorageMock.getItem.mockClear();
+ localStorageMock.setItem.mockClear();
+ });
+
+ describe('Basic Rendering', () => {
+ it('renders theme switcher', () => {
+ render(
+
+
+
+ );
+
+ // The ThemeSwitcher renders an SVG icon, not a button
+ const icon = document.querySelector('svg');
+ expect(icon).toBeInTheDocument();
+ });
+
+ it('shows moon icon for light theme', () => {
+ render(
+
+
+
+ );
+
+ // Moon icon should be present for light theme
+ const icon = document.querySelector('svg');
+ expect(icon).toBeInTheDocument();
+ });
+
+ it('shows sun icon for dark theme', () => {
+ render(
+
+
+
+ );
+
+ // Sun icon should be present for dark theme
+ const icon = document.querySelector('svg');
+ expect(icon).toBeInTheDocument();
+ });
+
+ it('applies custom size', () => {
+ render(
+
+
+
+ );
+
+ const icon = document.querySelector('svg');
+ expect(icon).toHaveAttribute('width', '40');
+ expect(icon).toHaveAttribute('height', '40');
+ });
+ });
+
+ describe('Theme Switching', () => {
+ it('switches from dark to light', () => {
+ const TestComponent = () => {
+ const { theme } = useTheme();
+ return (
+
+ {theme}
+
+
+ );
+ };
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByTestId('current-theme')).toHaveTextContent('dark');
+
+ const icon = document.querySelector('svg');
+ fireEvent.click(icon!);
+
+ expect(localStorageMock.setItem).toHaveBeenCalledWith('theme', 'light');
+ });
+ });
+});
diff --git a/packages/raystack/components/toast/__tests__/toast.test.tsx b/packages/raystack/components/toast/__tests__/toast.test.tsx
new file mode 100644
index 000000000..3585fcb18
--- /dev/null
+++ b/packages/raystack/components/toast/__tests__/toast.test.tsx
@@ -0,0 +1,185 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { ToastContainer, toast } from '../toast';
+
+describe('Toast', () => {
+ beforeEach(() => {
+ render();
+ });
+
+ describe('ToastContainer', () => {
+ it('renders ToastContainer component', () => {
+ expect(screen.getByLabelText('Notifications alt+T')).toBeInTheDocument();
+ });
+ });
+
+ describe('Toast Function', () => {
+ it('shows basic toast message', async () => {
+ toast('Hello World');
+ expect(await screen.findByText('Hello World')).toBeInTheDocument();
+ });
+
+ it('shows JSX content in toast', async () => {
+ const jsxContent = JSX Content
;
+ toast(jsxContent);
+ expect(await screen.findByTestId('jsx-content')).toBeInTheDocument();
+ });
+
+ const toastTypes = [
+ 'success',
+ 'error',
+ 'warning',
+ 'info',
+ 'loading'
+ ] as const;
+ toastTypes.forEach(type => {
+ it(`supports ${type} toast`, async () => {
+ toast[type]('Success message');
+ expect(await screen.findByText('Success message')).toBeInTheDocument();
+ });
+ });
+
+ it('supports custom toast with options', async () => {
+ const customOptions = {
+ duration: 5000,
+ description: 'Custom description'
+ };
+
+ toast('Custom toast', customOptions);
+ expect(await screen.findByText('Custom toast')).toBeInTheDocument();
+ });
+
+ it('supports promise-based toast', async () => {
+ const promise = new Promise(resolve => {
+ setTimeout(() => resolve('Promise resolved'), 100);
+ });
+
+ toast.promise(promise, {
+ loading: 'Loading...',
+ success: 'Success!',
+ error: 'Error!'
+ });
+
+ expect(await screen.findByText('Loading...')).toBeInTheDocument();
+
+ await waitFor(
+ () => {
+ expect(screen.getByText('Success!')).toBeInTheDocument();
+ },
+ { timeout: 200 }
+ );
+ });
+
+ it('supports dismiss functionality', async () => {
+ const toastId = toast('Dismissible toast');
+ expect(await screen.findByText('Dismissible toast')).toBeInTheDocument();
+
+ toast.dismiss(toastId);
+
+ await waitFor(() => {
+ expect(screen.queryByText('Dismissible toast')).not.toBeInTheDocument();
+ });
+ });
+
+ it('supports dismissAll functionality', async () => {
+ toast('First toast');
+ toast('Second toast');
+
+ expect(await screen.findByText('First toast')).toBeInTheDocument();
+ expect(await screen.findByText('Second toast')).toBeInTheDocument();
+
+ // Dismiss all toasts by calling dismiss without ID
+ toast.dismiss();
+
+ await waitFor(() => {
+ expect(screen.queryByText('First toast')).not.toBeInTheDocument();
+ expect(screen.queryByText('Second toast')).not.toBeInTheDocument();
+ });
+ });
+
+ it('handles multiple toasts simultaneously', async () => {
+ toast('First toast');
+ toast('Second toast');
+ toast('Third toast');
+
+ expect(await screen.findByText('First toast')).toBeInTheDocument();
+ expect(await screen.findByText('Second toast')).toBeInTheDocument();
+ expect(await screen.findByText('Third toast')).toBeInTheDocument();
+ });
+
+ it('supports custom action buttons', async () => {
+ toast('Toast with action', {
+ action: {
+ label: 'Undo',
+ onClick: () => console.log('Undo clicked')
+ }
+ });
+
+ expect(await screen.findByText('Toast with action')).toBeInTheDocument();
+ expect(await screen.findByText('Undo')).toBeInTheDocument();
+ });
+
+ it('supports custom duration', async () => {
+ const shortDuration = 100;
+ toast('Short duration toast', { duration: shortDuration });
+
+ expect(
+ await screen.findByText('Short duration toast')
+ ).toBeInTheDocument();
+
+ await waitFor(
+ () => {
+ expect(
+ screen.queryByText('Short duration toast')
+ ).not.toBeInTheDocument();
+ },
+ { timeout: 500 }
+ );
+ });
+
+ it('supports custom className', async () => {
+ const customClass = 'custom-toast-class';
+ toast('Custom class toast', { className: customClass });
+
+ expect(await screen.findByText('Custom class toast')).toBeInTheDocument();
+ });
+
+ it('supports custom style', async () => {
+ const customStyle = { backgroundColor: 'red' };
+ toast('Custom style toast', { style: customStyle });
+
+ expect(await screen.findByText('Custom style toast')).toBeInTheDocument();
+ });
+
+ it('supports onDismiss callback', async () => {
+ const onDismiss = vi.fn();
+ toast('Callback toast', { onDismiss });
+
+ expect(await screen.findByText('Callback toast')).toBeInTheDocument();
+
+ // Dismiss the toast
+ toast.dismiss();
+
+ await waitFor(() => {
+ expect(onDismiss).toHaveBeenCalled();
+ });
+ });
+
+ it('supports onAutoClose callback', async () => {
+ const onAutoClose = vi.fn();
+ toast('Auto close toast', {
+ duration: 100,
+ onAutoClose
+ });
+
+ expect(await screen.findByText('Auto close toast')).toBeInTheDocument();
+
+ await waitFor(
+ () => {
+ expect(onAutoClose).toHaveBeenCalled();
+ },
+ { timeout: 200 }
+ );
+ });
+ });
+});
diff --git a/packages/raystack/components/tooltip/__tests__/tooltip.test.tsx b/packages/raystack/components/tooltip/__tests__/tooltip.test.tsx
new file mode 100644
index 000000000..94a791e95
--- /dev/null
+++ b/packages/raystack/components/tooltip/__tests__/tooltip.test.tsx
@@ -0,0 +1,113 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it } from 'vitest';
+import { Tooltip } from '../tooltip';
+import { TooltipProps } from '../tooltip-root';
+
+const TRIGGER_TEXT = 'Hover me';
+const MESSAGE_TEXT = 'Tooltip text';
+
+const BasicTooltip = ({
+ message = MESSAGE_TEXT,
+ children = TRIGGER_TEXT,
+ ...props
+}: Partial) => {
+ return (
+
+
+
+ );
+};
+describe('Tooltip', () => {
+ describe('Basic Rendering', () => {
+ it('renders trigger content', () => {
+ render();
+ expect(screen.getByText(TRIGGER_TEXT)).toBeInTheDocument();
+ });
+
+ it('does not show tooltip initially', () => {
+ render();
+ expect(screen.queryByText(MESSAGE_TEXT)).not.toBeInTheDocument();
+ });
+
+ it('respects open prop', () => {
+ render();
+ expect(screen.queryAllByText(MESSAGE_TEXT)[0]).toBeInTheDocument();
+ });
+
+ it('shows tooltip on hover', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const trigger = screen.getByText(TRIGGER_TEXT);
+ await user.hover(trigger);
+
+ expect(screen.getAllByText(MESSAGE_TEXT)[0]).toBeInTheDocument();
+ });
+ it('hides tooltip on mouse leave', async () => {
+ const user = userEvent.setup();
+ render(
+ <>
+
+ Trigger2
+ >
+ );
+
+ const trigger = screen.getByText(TRIGGER_TEXT);
+ const trigger2 = screen.getByText('Trigger2');
+ await user.hover(trigger);
+ await user.hover(trigger2);
+
+ expect(screen.queryByText(MESSAGE_TEXT)).not.toBeInTheDocument();
+ });
+
+ it('shows tooltip on focus', async () => {
+ render();
+
+ const trigger = screen.getByText(TRIGGER_TEXT);
+ await trigger.focus();
+
+ expect(screen.getAllByText(MESSAGE_TEXT)[0]).toBeInTheDocument();
+ });
+
+ it('hides tooltip on blur', async () => {
+ render();
+
+ const trigger = screen.getByText(TRIGGER_TEXT);
+ await trigger.focus();
+ await trigger.blur();
+
+ expect(screen.queryByText(MESSAGE_TEXT)).not.toBeInTheDocument();
+ });
+
+ it('calls onOpenChange when state changes', async () => {
+ const user = userEvent.setup();
+ const onOpenChange = vi.fn();
+ render();
+ const trigger = screen.getByText(TRIGGER_TEXT);
+
+ await user.hover(trigger);
+ expect(onOpenChange).toHaveBeenCalled();
+ });
+ });
+
+ describe('Provider', () => {
+ it('works with explicit provider', () => {
+ render(
+
+ Trigger 1
+ Trigger 2
+
+ );
+
+ expect(screen.getByText('Trigger 1')).toBeInTheDocument();
+ expect(screen.getByText('Trigger 2')).toBeInTheDocument();
+ });
+
+ it('works without explicit provider', () => {
+ render();
+
+ expect(screen.getByText(TRIGGER_TEXT)).toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/raystack/test-utils.tsx b/packages/raystack/test-utils.tsx
deleted file mode 100644
index d39b4439e..000000000
--- a/packages/raystack/test-utils.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { render as rtlRender, RenderOptions, RenderResult } from '@testing-library/react';
-import { ReactElement } from 'react';
-
-function render(ui: ReactElement, options: RenderOptions = {}): RenderResult {
- return rtlRender(ui, {
- wrapper: ({ children }) => children,
- ...options,
- });
-}
-
-export * from '@testing-library/react';
-export { render };
\ No newline at end of file
diff --git a/packages/raystack/tsconfig.json b/packages/raystack/tsconfig.json
index 72a675774..bc3887ac3 100644
--- a/packages/raystack/tsconfig.json
+++ b/packages/raystack/tsconfig.json
@@ -11,7 +11,7 @@
"~/icons/*": ["icons/*"],
"~/hooks/*": ["hooks/*"]
},
- "types": ["jest", "@testing-library/jest-dom"]
+ "types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": [".", "icons", "hooks", "global.d.ts"],
"exclude": ["dist", "build", "node_modules"]
diff --git a/packages/raystack/vitest.setup.ts b/packages/raystack/vitest.setup.ts
index 6df58f0f9..78bfba3eb 100644
--- a/packages/raystack/vitest.setup.ts
+++ b/packages/raystack/vitest.setup.ts
@@ -1 +1,8 @@
-import '@testing-library/jest-dom/vitest';
\ No newline at end of file
+import '@testing-library/jest-dom/vitest';
+
+// Polyfill ResizeObserver for tests
+global.ResizeObserver = class ResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+};