diff --git a/packages/react/src/hooks/__tests__/useCrudShortcuts.test.ts b/packages/react/src/hooks/__tests__/useCrudShortcuts.test.ts new file mode 100644 index 000000000..0fee36885 --- /dev/null +++ b/packages/react/src/hooks/__tests__/useCrudShortcuts.test.ts @@ -0,0 +1,85 @@ +/** + * Tests for useCrudShortcuts hook + */ +import { describe, it, expect, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useCrudShortcuts } from '../useCrudShortcuts'; + +describe('useCrudShortcuts', () => { + it('renders without error with empty callbacks', () => { + expect(() => { + renderHook(() => useCrudShortcuts({})); + }).not.toThrow(); + }); + + it('renders without error when disabled', () => { + expect(() => { + renderHook(() => useCrudShortcuts({}, false)); + }).not.toThrow(); + }); + + it('registers onCreate shortcut', () => { + const onCreate = vi.fn(); + expect(() => { + renderHook(() => useCrudShortcuts({ onCreate })); + }).not.toThrow(); + }); + + it('registers onEdit shortcut', () => { + const onEdit = vi.fn(); + expect(() => { + renderHook(() => useCrudShortcuts({ onEdit })); + }).not.toThrow(); + }); + + it('registers onDelete shortcut', () => { + const onDelete = vi.fn(); + expect(() => { + renderHook(() => useCrudShortcuts({ onDelete })); + }).not.toThrow(); + }); + + it('registers onSave shortcut', () => { + const onSave = vi.fn(); + expect(() => { + renderHook(() => useCrudShortcuts({ onSave })); + }).not.toThrow(); + }); + + it('registers onDuplicate shortcut', () => { + const onDuplicate = vi.fn(); + expect(() => { + renderHook(() => useCrudShortcuts({ onDuplicate })); + }).not.toThrow(); + }); + + it('registers onCancel shortcut', () => { + const onCancel = vi.fn(); + expect(() => { + renderHook(() => useCrudShortcuts({ onCancel })); + }).not.toThrow(); + }); + + it('registers onSearch shortcut', () => { + const onSearch = vi.fn(); + expect(() => { + renderHook(() => useCrudShortcuts({ onSearch })); + }).not.toThrow(); + }); + + it('registers all shortcuts together', () => { + expect(() => { + renderHook(() => + useCrudShortcuts({ + onCreate: vi.fn(), + onEdit: vi.fn(), + onDelete: vi.fn(), + onSave: vi.fn(), + onDuplicate: vi.fn(), + onCancel: vi.fn(), + onSearch: vi.fn(), + }) + ); + }).not.toThrow(); + }); +}); diff --git a/packages/react/src/hooks/__tests__/useFocusTrap.test.ts b/packages/react/src/hooks/__tests__/useFocusTrap.test.ts index f6ac938e2..5421e4657 100644 --- a/packages/react/src/hooks/__tests__/useFocusTrap.test.ts +++ b/packages/react/src/hooks/__tests__/useFocusTrap.test.ts @@ -44,4 +44,251 @@ describe('useFocusTrap', () => { // Should not throw expect(result.current.current).toBeNull(); }); + + it('auto-focuses the first focusable element when enabled', () => { + const container = document.createElement('div'); + const button = document.createElement('button'); + button.textContent = 'Click me'; + container.appendChild(button); + document.body.appendChild(container); + + const { result } = renderHook(() => + useFocusTrap({ enabled: true, autoFocus: true }) + ); + + act(() => { + (result.current as any).current = container; + }); + + // Re-render to trigger effect with the ref set + const { result: result2 } = renderHook(() => + useFocusTrap({ enabled: true, autoFocus: true }) + ); + + document.body.removeChild(container); + }); + + it('focuses initialFocus selector when provided', () => { + const container = document.createElement('div'); + const input1 = document.createElement('input'); + const input2 = document.createElement('input'); + input2.className = 'target-input'; + container.appendChild(input1); + container.appendChild(input2); + document.body.appendChild(container); + + const focusSpy = vi.spyOn(input2, 'focus'); + + const { result, rerender } = renderHook( + ({ enabled }) => + useFocusTrap({ + enabled, + autoFocus: true, + initialFocus: '.target-input', + }), + { initialProps: { enabled: false } } + ); + + // Set the ref + (result.current as any).current = container; + + // Enable the trap to trigger the effect + rerender({ enabled: true }); + + expect(focusSpy).toHaveBeenCalled(); + + document.body.removeChild(container); + }); + + it('handles Escape key when escapeDeactivates is true', () => { + const container = document.createElement('div'); + const button = document.createElement('button'); + container.appendChild(button); + document.body.appendChild(container); + + const onEscape = vi.fn(); + + const { result, rerender } = renderHook( + ({ enabled }) => + useFocusTrap({ + enabled, + escapeDeactivates: true, + onEscape, + }), + { initialProps: { enabled: false } } + ); + + (result.current as any).current = container; + rerender({ enabled: true }); + + act(() => { + const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }); + container.dispatchEvent(event); + }); + + expect(onEscape).toHaveBeenCalled(); + + document.body.removeChild(container); + }); + + it('traps Tab focus at the end of focusable elements', () => { + const container = document.createElement('div'); + const button1 = document.createElement('button'); + button1.textContent = 'First'; + const button2 = document.createElement('button'); + button2.textContent = 'Last'; + container.appendChild(button1); + container.appendChild(button2); + document.body.appendChild(container); + + const { result, rerender } = renderHook( + ({ enabled }) => + useFocusTrap({ + enabled, + autoFocus: false, + }), + { initialProps: { enabled: false } } + ); + + (result.current as any).current = container; + rerender({ enabled: true }); + + // Focus the last element + button2.focus(); + + act(() => { + const event = new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + }); + container.dispatchEvent(event); + }); + + document.body.removeChild(container); + }); + + it('traps Shift+Tab focus at the start of focusable elements', () => { + const container = document.createElement('div'); + const button1 = document.createElement('button'); + button1.textContent = 'First'; + const button2 = document.createElement('button'); + button2.textContent = 'Last'; + container.appendChild(button1); + container.appendChild(button2); + document.body.appendChild(container); + + const { result, rerender } = renderHook( + ({ enabled }) => + useFocusTrap({ + enabled, + autoFocus: false, + }), + { initialProps: { enabled: false } } + ); + + (result.current as any).current = container; + rerender({ enabled: true }); + + // Focus the first element + button1.focus(); + + act(() => { + const event = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: true, + bubbles: true, + }); + container.dispatchEvent(event); + }); + + document.body.removeChild(container); + }); + + it('restores focus on unmount when restoreFocus is true', () => { + const outsideButton = document.createElement('button'); + outsideButton.textContent = 'Outside'; + document.body.appendChild(outsideButton); + outsideButton.focus(); + + const container = document.createElement('div'); + const innerButton = document.createElement('button'); + innerButton.textContent = 'Inside'; + container.appendChild(innerButton); + document.body.appendChild(container); + + const { result, rerender, unmount } = renderHook( + ({ enabled }) => + useFocusTrap({ + enabled, + autoFocus: true, + restoreFocus: true, + }), + { initialProps: { enabled: false } } + ); + + (result.current as any).current = container; + rerender({ enabled: true }); + + unmount(); + + document.body.removeChild(container); + document.body.removeChild(outsideButton); + }); + + it('ignores non-Tab and non-Escape keys', () => { + const container = document.createElement('div'); + const button = document.createElement('button'); + container.appendChild(button); + document.body.appendChild(container); + + const { result, rerender } = renderHook( + ({ enabled }) => + useFocusTrap({ + enabled, + autoFocus: false, + }), + { initialProps: { enabled: false } } + ); + + (result.current as any).current = container; + rerender({ enabled: true }); + + act(() => { + const event = new KeyboardEvent('keydown', { + key: 'a', + bubbles: true, + }); + container.dispatchEvent(event); + }); + + document.body.removeChild(container); + }); + + it('handles container with no focusable elements', () => { + const container = document.createElement('div'); + container.innerHTML = 'No focusable elements'; + document.body.appendChild(container); + + const { result, rerender } = renderHook( + ({ enabled }) => + useFocusTrap({ + enabled, + autoFocus: true, + }), + { initialProps: { enabled: false } } + ); + + (result.current as any).current = container; + rerender({ enabled: true }); + + act(() => { + const event = new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + }); + container.dispatchEvent(event); + }); + + document.body.removeChild(container); + }); }); diff --git a/packages/react/src/hooks/__tests__/usePageTransition.test.ts b/packages/react/src/hooks/__tests__/usePageTransition.test.ts new file mode 100644 index 000000000..ba4dc4c6c --- /dev/null +++ b/packages/react/src/hooks/__tests__/usePageTransition.test.ts @@ -0,0 +1,158 @@ +/** + * Tests for usePageTransition hook + */ +import { describe, it, expect, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { usePageTransition } from '../usePageTransition'; + +describe('usePageTransition', () => { + + it('returns inactive result when type is none (default)', () => { + const { result } = renderHook(() => usePageTransition()); + expect(result.current.isActive).toBe(false); + expect(result.current.enterClassName).toBe(''); + expect(result.current.exitClassName).toBe(''); + expect(result.current.type).toBe('none'); + }); + + it('returns active result for fade transition', () => { + const { result } = renderHook(() => usePageTransition({ type: 'fade' })); + expect(result.current.isActive).toBe(true); + expect(result.current.enterClassName).toContain('fade-in'); + expect(result.current.exitClassName).toContain('fade-out'); + expect(result.current.type).toBe('fade'); + }); + + it('returns correct classes for slide_up transition', () => { + const { result } = renderHook(() => usePageTransition({ type: 'slide_up' })); + expect(result.current.isActive).toBe(true); + expect(result.current.enterClassName).toContain('slide-in-from-bottom'); + expect(result.current.exitClassName).toContain('slide-out-to-top'); + }); + + it('returns correct classes for slide_down transition', () => { + const { result } = renderHook(() => usePageTransition({ type: 'slide_down' })); + expect(result.current.enterClassName).toContain('slide-in-from-top'); + expect(result.current.exitClassName).toContain('slide-out-to-bottom'); + }); + + it('returns correct classes for slide_left transition', () => { + const { result } = renderHook(() => usePageTransition({ type: 'slide_left' })); + expect(result.current.enterClassName).toContain('slide-in-from-right'); + expect(result.current.exitClassName).toContain('slide-out-to-left'); + }); + + it('returns correct classes for slide_right transition', () => { + const { result } = renderHook(() => usePageTransition({ type: 'slide_right' })); + expect(result.current.enterClassName).toContain('slide-in-from-left'); + expect(result.current.exitClassName).toContain('slide-out-to-right'); + }); + + it('returns correct classes for scale transition', () => { + const { result } = renderHook(() => usePageTransition({ type: 'scale' })); + expect(result.current.enterClassName).toContain('zoom-in'); + expect(result.current.exitClassName).toContain('zoom-out'); + }); + + it('returns correct classes for rotate transition', () => { + const { result } = renderHook(() => usePageTransition({ type: 'rotate' })); + expect(result.current.enterClassName).toContain('spin-in'); + expect(result.current.exitClassName).toContain('spin-out'); + }); + + it('returns correct classes for flip transition', () => { + const { result } = renderHook(() => usePageTransition({ type: 'flip' })); + expect(result.current.enterClassName).toContain('zoom-in'); + expect(result.current.exitClassName).toContain('zoom-out'); + }); + + it('uses default duration of 300ms', () => { + const { result } = renderHook(() => usePageTransition({ type: 'fade' })); + expect(result.current.duration).toBe(300); + expect(result.current.enterStyle.animationDuration).toBe('300ms'); + }); + + it('accepts custom duration', () => { + const { result } = renderHook(() => usePageTransition({ type: 'fade', duration: 500 })); + expect(result.current.duration).toBe(500); + expect(result.current.enterStyle.animationDuration).toBe('500ms'); + }); + + it('uses ease_in_out easing by default', () => { + const { result } = renderHook(() => usePageTransition({ type: 'fade' })); + expect(result.current.enterStyle.animationTimingFunction).toBe('ease-in-out'); + }); + + it('accepts custom easing', () => { + const { result } = renderHook(() => usePageTransition({ type: 'fade', easing: 'spring' })); + expect(result.current.enterStyle.animationTimingFunction).toBe('cubic-bezier(0.34, 1.56, 0.64, 1)'); + }); + + it('sets animationFillMode to both', () => { + const { result } = renderHook(() => usePageTransition({ type: 'fade' })); + expect(result.current.enterStyle.animationFillMode).toBe('both'); + expect(result.current.exitStyle.animationFillMode).toBe('both'); + }); + + it('applies crossFade positioning', () => { + const { result } = renderHook(() => usePageTransition({ type: 'fade', crossFade: true })); + expect(result.current.enterStyle.position).toBe('absolute'); + expect(result.current.enterStyle.inset).toBe('0'); + expect(result.current.exitStyle.position).toBe('absolute'); + expect(result.current.exitStyle.inset).toBe('0'); + }); + + it('does not apply crossFade positioning when disabled', () => { + const { result } = renderHook(() => usePageTransition({ type: 'fade', crossFade: false })); + expect(result.current.enterStyle.position).toBeUndefined(); + expect(result.current.exitStyle.position).toBeUndefined(); + }); + + it('returns inactive when reduced motion is preferred', () => { + // Mock window.matchMedia to simulate reduced motion preference + const original = window.matchMedia; + window.matchMedia = vi.fn((query: string) => ({ + matches: query === '(prefers-reduced-motion: reduce)', + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as unknown as typeof window.matchMedia; + + const { result } = renderHook(() => usePageTransition({ type: 'fade' })); + expect(result.current.isActive).toBe(false); + expect(result.current.enterClassName).toBe(''); + expect(result.current.exitClassName).toBe(''); + + window.matchMedia = original; + }); + + it('returns empty styles when inactive', () => { + const { result } = renderHook(() => usePageTransition({ type: 'none' })); + expect(result.current.enterStyle).toEqual({}); + expect(result.current.exitStyle).toEqual({}); + }); + + it('accepts linear easing', () => { + const { result } = renderHook(() => usePageTransition({ type: 'fade', easing: 'linear' })); + expect(result.current.enterStyle.animationTimingFunction).toBe('linear'); + }); + + it('accepts ease easing', () => { + const { result } = renderHook(() => usePageTransition({ type: 'fade', easing: 'ease' })); + expect(result.current.enterStyle.animationTimingFunction).toBe('ease'); + }); + + it('accepts ease_in easing', () => { + const { result } = renderHook(() => usePageTransition({ type: 'fade', easing: 'ease_in' })); + expect(result.current.enterStyle.animationTimingFunction).toBe('ease-in'); + }); + + it('accepts ease_out easing', () => { + const { result } = renderHook(() => usePageTransition({ type: 'fade', easing: 'ease_out' })); + expect(result.current.enterStyle.animationTimingFunction).toBe('ease-out'); + }); +}); diff --git a/packages/react/src/hooks/__tests__/useReducedMotion.test.ts b/packages/react/src/hooks/__tests__/useReducedMotion.test.ts new file mode 100644 index 000000000..d67d04b03 --- /dev/null +++ b/packages/react/src/hooks/__tests__/useReducedMotion.test.ts @@ -0,0 +1,104 @@ +/** + * Tests for useReducedMotion hook + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useReducedMotion } from '../useReducedMotion'; + +describe('useReducedMotion', () => { + let originalMatchMedia: typeof window.matchMedia; + let listeners: Map void)[]>; + + beforeEach(() => { + originalMatchMedia = window.matchMedia; + listeners = new Map(); + + window.matchMedia = vi.fn((query: string) => { + const mediaQueryList = { + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn((event: string, handler: (event: MediaQueryListEvent) => void) => { + const key = `${query}:${event}`; + if (!listeners.has(key)) { + listeners.set(key, []); + } + listeners.get(key)!.push(handler); + }), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + } as unknown as MediaQueryList; + return mediaQueryList; + }); + }); + + afterEach(() => { + window.matchMedia = originalMatchMedia; + listeners.clear(); + }); + + it('returns false when prefers-reduced-motion is not set', () => { + const { result } = renderHook(() => useReducedMotion()); + expect(result.current).toBe(false); + }); + + it('returns true when prefers-reduced-motion is set', () => { + window.matchMedia = vi.fn((query: string) => ({ + matches: true, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as unknown as typeof window.matchMedia; + + const { result } = renderHook(() => useReducedMotion()); + expect(result.current).toBe(true); + }); + + it('registers a change event listener', () => { + renderHook(() => useReducedMotion()); + + const key = '(prefers-reduced-motion: reduce):change'; + expect(listeners.has(key)).toBe(true); + expect(listeners.get(key)!.length).toBe(1); + }); + + it('updates when media query changes', () => { + const { result } = renderHook(() => useReducedMotion()); + + expect(result.current).toBe(false); + + const key = '(prefers-reduced-motion: reduce):change'; + const handler = listeners.get(key)![0]; + + act(() => { + handler({ matches: true } as MediaQueryListEvent); + }); + + expect(result.current).toBe(true); + }); + + it('cleans up event listener on unmount', () => { + const removeEventListener = vi.fn(); + window.matchMedia = vi.fn((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener, + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as unknown as typeof window.matchMedia; + + const { unmount } = renderHook(() => useReducedMotion()); + unmount(); + + expect(removeEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index 84459d7f6..105bafdbe 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -60,9 +60,14 @@ export default defineConfig({ * Build the console app and serve the production bundle via `vite preview`. * This mirrors the Vercel deployment pipeline and catches blank-page issues * caused by build-time errors (broken imports, missing polyfills, etc.). + * + * In CI the "Build Core" job already produces the dist/ artifacts and the + * E2E job downloads them, so we skip the build step and only serve. */ webServer: { - command: 'pnpm turbo run build --filter=@object-ui/console && pnpm --filter @object-ui/console preview --port 4173', + command: process.env.CI + ? 'pnpm --filter @object-ui/console preview --port 4173' + : 'pnpm turbo run build --filter=@object-ui/console && pnpm --filter @object-ui/console preview --port 4173', url: 'http://localhost:4173/console/', reuseExistingServer: !process.env.CI, timeout: 180 * 1000,