UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

491 lines 21.7 kB
import { createInjector } from '@furystack/inject'; import { createComponent, flushUpdates, initializeShadeRoot } from '@furystack/shades'; import { usingAsync } from '@furystack/utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { defaultDarkTheme } from '../themes/default-dark-theme.js'; import { NotyService } from '../services/noty-service.js'; import { ThemeProviderService } from '../services/theme-provider-service.js'; import { getDefaultNotyTimeouts, NotyComponent, NotyList } from './noty-list.js'; describe('getDefaultNotyTimeouts', () => { it('should return 0 for error type', () => { expect(getDefaultNotyTimeouts('error')).toBe(0); }); it('should return 0 for warning type', () => { expect(getDefaultNotyTimeouts('warning')).toBe(0); }); it('should return 5000 for success type', () => { expect(getDefaultNotyTimeouts('success')).toBe(5000); }); it('should return 20000 for info type', () => { expect(getDefaultNotyTimeouts('info')).toBe(20000); }); it('should return 0 for unknown type', () => { expect(getDefaultNotyTimeouts('unknown')).toBe(0); }); }); describe('NotyComponent', () => { let originalAnimate; let animateCalls; const setupTheme = (injector) => { const themeProvider = injector.get(ThemeProviderService); themeProvider.setAssignedTheme(defaultDarkTheme); }; beforeEach(() => { document.body.innerHTML = '<div id="root"></div>'; animateCalls = []; originalAnimate = Element.prototype.animate; Element.prototype.animate = vi.fn((keyframes, options) => { animateCalls.push({ keyframes, options }); const mockAnimation = { onfinish: null, oncancel: null, cancel: vi.fn(), play: vi.fn(), pause: vi.fn(), finish: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), }; return mockAnimation; }); }); afterEach(() => { document.body.innerHTML = ''; Element.prototype.animate = originalAnimate; vi.restoreAllMocks(); }); it('should render the shade-noty custom element', async () => { await usingAsync(createInjector(), async (injector) => { setupTheme(injector); const rootElement = document.getElementById('root'); const model = { type: 'info', title: 'Test', body: 'Test body' }; initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(NotyComponent, { model: model, onDismiss: () => { } }), }); await flushUpdates(); const noty = document.querySelector('shade-noty'); expect(noty).not.toBeNull(); expect(noty?.tagName.toLowerCase()).toBe('shade-noty'); }); }); it('should render title and body content', async () => { await usingAsync(createInjector(), async (injector) => { setupTheme(injector); const rootElement = document.getElementById('root'); const model = { type: 'success', title: 'Success Title', body: 'Success message body' }; initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(NotyComponent, { model: model, onDismiss: () => { } }), }); await flushUpdates(); expect(document.body.innerHTML).toContain('Success Title'); expect(document.body.innerHTML).toContain('Success message body'); }); }); it('should apply noty class with type on the element', async () => { await usingAsync(createInjector(), async (injector) => { setupTheme(injector); const rootElement = document.getElementById('root'); const model = { type: 'error', title: 'Error', body: 'Error occurred' }; initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(NotyComponent, { model: model, onDismiss: () => { } }), }); await flushUpdates(); const noty = document.querySelector('shade-noty'); expect(noty).not.toBeNull(); expect(noty.getAttribute('data-noty-type')).toBe('error'); }); }); it('should render dismiss button', async () => { await usingAsync(createInjector(), async (injector) => { setupTheme(injector); const rootElement = document.getElementById('root'); const model = { type: 'warning', title: 'Warning', body: 'Warning message' }; initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(NotyComponent, { model: model, onDismiss: () => { } }), }); await flushUpdates(); const dismissButton = document.querySelector('.dismiss-button'); expect(dismissButton).not.toBeNull(); expect(dismissButton?.querySelector('shade-icon')).not.toBeNull(); }); }); it('should call onDismiss when dismiss button is clicked', async () => { vi.useFakeTimers(); const onDismiss = vi.fn(); Element.prototype.animate = vi.fn((keyframes, options) => { animateCalls.push({ keyframes, options }); const mockAnimation = { onfinish: null, oncancel: null, cancel: vi.fn(), play: vi.fn(), pause: vi.fn(), finish: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), }; setTimeout(() => { mockAnimation.onfinish?.({}); }, 10); return mockAnimation; }); await usingAsync(createInjector(), async (injector) => { setupTheme(injector); const rootElement = document.getElementById('root'); const model = { type: 'info', title: 'Info', body: 'Info message' }; initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(NotyComponent, { model: model, onDismiss: onDismiss }), }); await vi.advanceTimersByTimeAsync(50); const dismissButton = document.querySelector('.dismiss-button'); expect(dismissButton).not.toBeNull(); dismissButton.click(); await vi.advanceTimersByTimeAsync(50); expect(onDismiss).toHaveBeenCalled(); }); vi.useRealTimers(); }); it('should start fade-in animation on mount', async () => { vi.useFakeTimers(); await usingAsync(createInjector(), async (injector) => { setupTheme(injector); const rootElement = document.getElementById('root'); const model = { type: 'success', title: 'Success', body: 'Success message' }; initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(NotyComponent, { model: model, onDismiss: () => { } }), }); // Wait for render await vi.advanceTimersByTimeAsync(50); // The useDisposable hook schedules the animation via setTimeout await vi.advanceTimersByTimeAsync(10); const fadeInCall = animateCalls.find((call) => Array.isArray(call.keyframes) && call.keyframes.some((kf) => 'opacity' in kf && 'height' in kf)); expect(fadeInCall).toBeDefined(); expect(fadeInCall?.options?.duration).toBe(500); expect(fadeInCall?.options?.fill).toBe('forwards'); vi.useRealTimers(); }); }); it('should auto-dismiss after timeout for success type', async () => { vi.useFakeTimers(); const onDismiss = vi.fn(); Element.prototype.animate = vi.fn((keyframes, options) => { animateCalls.push({ keyframes, options }); const mockAnimation = { onfinish: null, oncancel: null, cancel: vi.fn(), play: vi.fn(), pause: vi.fn(), finish: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), }; setTimeout(() => { mockAnimation.onfinish?.({}); }, 10); return mockAnimation; }); await usingAsync(createInjector(), async (injector) => { setupTheme(injector); const rootElement = document.getElementById('root'); const model = { type: 'success', title: 'Success', body: 'Success message' }; initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(NotyComponent, { model: model, onDismiss: onDismiss }), }); await vi.advanceTimersByTimeAsync(50); expect(onDismiss).not.toHaveBeenCalled(); // Success timeout is 5000ms await vi.advanceTimersByTimeAsync(5000); await vi.advanceTimersByTimeAsync(50); expect(onDismiss).toHaveBeenCalled(); vi.useRealTimers(); }); }); it('should use custom timeout when provided', async () => { vi.useFakeTimers(); const onDismiss = vi.fn(); Element.prototype.animate = vi.fn((keyframes, options) => { animateCalls.push({ keyframes, options }); const mockAnimation = { onfinish: null, oncancel: null, cancel: vi.fn(), play: vi.fn(), pause: vi.fn(), finish: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), }; setTimeout(() => { mockAnimation.onfinish?.({}); }, 10); return mockAnimation; }); await usingAsync(createInjector(), async (injector) => { setupTheme(injector); const rootElement = document.getElementById('root'); // Info default timeout is 20000, but we set custom 1000 const model = { type: 'info', title: 'Info', body: 'Info message', timeout: 1000 }; initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(NotyComponent, { model: model, onDismiss: onDismiss }), }); await vi.advanceTimersByTimeAsync(50); expect(onDismiss).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1000); await vi.advanceTimersByTimeAsync(50); expect(onDismiss).toHaveBeenCalled(); vi.useRealTimers(); }); }); it('should not auto-dismiss for error type (timeout 0)', async () => { vi.useFakeTimers(); const onDismiss = vi.fn(); await usingAsync(createInjector(), async (injector) => { setupTheme(injector); const rootElement = document.getElementById('root'); const model = { type: 'error', title: 'Error', body: 'Error message' }; initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(NotyComponent, { model: model, onDismiss: onDismiss }), }); await vi.advanceTimersByTimeAsync(50); await vi.advanceTimersByTimeAsync(30000); expect(onDismiss).not.toHaveBeenCalled(); vi.useRealTimers(); }); }); it('should render all noty types with appropriate styling', async () => { const types = ['error', 'warning', 'info', 'success']; for (const type of types) { document.body.innerHTML = '<div id="root"></div>'; await usingAsync(createInjector(), async (injector) => { setupTheme(injector); const rootElement = document.getElementById('root'); const model = { type, title: `${type} Title`, body: `${type} body` }; initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(NotyComponent, { model: model, onDismiss: () => { } }), }); await flushUpdates(); const noty = document.querySelector('shade-noty'); expect(noty).not.toBeNull(); expect(noty.getAttribute('data-noty-type')).toBe(type); }); } }); it('should apply background color from theme', async () => { await usingAsync(createInjector(), async (injector) => { setupTheme(injector); const rootElement = document.getElementById('root'); const model = { type: 'success', title: 'Success', body: 'Message' }; initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(NotyComponent, { model: model, onDismiss: () => { } }), }); await flushUpdates(); const noty = document.querySelector('shade-noty'); expect(noty).not.toBeNull(); // The component sets background color via CSS custom property expect(noty.style.getPropertyValue('--noty-bg')).toBeTruthy(); expect(noty.style.getPropertyValue('--noty-text')).toBeTruthy(); }); }); }); describe('NotyList', () => { let originalAnimate; let animateCalls; const setupTheme = (injector) => { const themeProvider = injector.get(ThemeProviderService); themeProvider.setAssignedTheme(defaultDarkTheme); }; beforeEach(() => { document.body.innerHTML = '<div id="root"></div>'; animateCalls = []; originalAnimate = Element.prototype.animate; Element.prototype.animate = vi.fn((keyframes, options) => { animateCalls.push({ keyframes, options }); const mockAnimation = { onfinish: null, oncancel: null, cancel: vi.fn(), play: vi.fn(), pause: vi.fn(), finish: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), }; return mockAnimation; }); }); afterEach(() => { document.body.innerHTML = ''; Element.prototype.animate = originalAnimate; vi.restoreAllMocks(); }); it('should render the shade-noty-list custom element', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(NotyList, null), }); await flushUpdates(); const notyList = document.querySelector('shade-noty-list'); expect(notyList).not.toBeNull(); expect(notyList?.tagName.toLowerCase()).toBe('shade-noty-list'); }); }); it('should have fixed positioning styles', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(NotyList, null), }); await flushUpdates(); const notyList = document.querySelector('shade-noty-list'); expect(notyList).not.toBeNull(); const computedStyle = window.getComputedStyle(notyList); expect(computedStyle.position).toBe('fixed'); expect(computedStyle.display).toBe('flex'); expect(computedStyle.flexDirection).toBe('column'); }); }); it('should render existing notys from NotyService', async () => { await usingAsync(createInjector(), async (injector) => { setupTheme(injector); const notyService = injector.get(NotyService); const noty1 = { type: 'info', title: 'Info 1', body: 'Info body 1' }; const noty2 = { type: 'success', title: 'Success 1', body: 'Success body 1' }; notyService.emit('onNotyAdded', noty1); notyService.emit('onNotyAdded', noty2); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(NotyList, null), }); await flushUpdates(); const notys = document.querySelectorAll('shade-noty'); expect(notys.length).toBe(2); }); }); it('should add new noty when NotyService emits onNotyAdded', async () => { await usingAsync(createInjector(), async (injector) => { setupTheme(injector); const notyService = injector.get(NotyService); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(NotyList, null), }); await flushUpdates(); let notys = document.querySelectorAll('shade-noty'); expect(notys.length).toBe(0); const newNoty = { type: 'warning', title: 'Warning', body: 'Warning message' }; notyService.emit('onNotyAdded', newNoty); await flushUpdates(); notys = document.querySelectorAll('shade-noty'); expect(notys.length).toBe(1); }); }); it('should remove noty when NotyService emits onNotyRemoved', async () => { await usingAsync(createInjector(), async (injector) => { setupTheme(injector); const notyService = injector.get(NotyService); const noty = { type: 'error', title: 'Error', body: 'Error message' }; notyService.emit('onNotyAdded', noty); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(NotyList, null), }); await flushUpdates(); let notys = document.querySelectorAll('shade-noty'); expect(notys.length).toBe(1); notyService.emit('onNotyRemoved', noty); await flushUpdates(); notys = document.querySelectorAll('shade-noty'); expect(notys.length).toBe(0); }); }); it('should handle multiple notys being added and removed', async () => { await usingAsync(createInjector(), async (injector) => { setupTheme(injector); const notyService = injector.get(NotyService); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(NotyList, null), }); await flushUpdates(); const noty1 = { type: 'info', title: 'Info 1', body: 'Body 1' }; const noty2 = { type: 'success', title: 'Success 1', body: 'Body 2' }; const noty3 = { type: 'warning', title: 'Warning 1', body: 'Body 3' }; notyService.emit('onNotyAdded', noty1); notyService.emit('onNotyAdded', noty2); notyService.emit('onNotyAdded', noty3); await flushUpdates(); let notys = document.querySelectorAll('shade-noty'); expect(notys.length).toBe(3); notyService.emit('onNotyRemoved', noty2); await flushUpdates(); notys = document.querySelectorAll('shade-noty'); expect(notys.length).toBe(2); notyService.emit('onNotyRemoved', noty1); notyService.emit('onNotyRemoved', noty3); await flushUpdates(); notys = document.querySelectorAll('shade-noty'); expect(notys.length).toBe(0); }); }); it('should only remove the specific noty that was requested', async () => { await usingAsync(createInjector(), async (injector) => { setupTheme(injector); const notyService = injector.get(NotyService); const rootElement = document.getElementById('root'); const noty1 = { type: 'info', title: 'Keep this', body: 'Body 1' }; const noty2 = { type: 'error', title: 'Remove this', body: 'Body 2' }; notyService.emit('onNotyAdded', noty1); notyService.emit('onNotyAdded', noty2); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(NotyList, null), }); await flushUpdates(); notyService.emit('onNotyRemoved', noty2); await flushUpdates(); const notys = document.querySelectorAll('shade-noty'); expect(notys.length).toBe(1); // Check that the remaining noty is the correct one expect(document.body.innerHTML).toContain('Keep this'); expect(document.body.innerHTML).not.toContain('Remove this'); }); }); }); //# sourceMappingURL=noty-list.spec.js.map