UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

605 lines 29.1 kB
import { createInjector } from '@furystack/inject'; import { createComponent, flushUpdates, initializeShadeRoot } from '@furystack/shades'; import { usingAsync } from '@furystack/utils'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { Image, ImageGroup } from './image.js'; describe('Image component', () => { beforeEach(() => { document.body.innerHTML = '<div id="root"></div>'; }); afterEach(() => { document.body.innerHTML = ''; }); it('should render an image with the provided src and alt', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); const testSrc = 'https://example.com/photo.jpg'; const testAlt = 'Test photo'; initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Image, { src: testSrc, alt: testAlt }), }); await flushUpdates(); const imageComponent = document.querySelector('shade-image'); expect(imageComponent).not.toBeNull(); const img = imageComponent?.querySelector('img'); expect(img).not.toBeNull(); expect(img?.src).toBe(testSrc); expect(img?.alt).toBe(testAlt); }); }); it('should render with default alt when not provided', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Image, { src: "https://example.com/photo.jpg" }), }); await flushUpdates(); const img = document.querySelector('shade-image img'); expect(img).not.toBeNull(); expect(img?.alt).toBe(''); }); }); it('should apply width and height props', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Image, { src: "https://example.com/photo.jpg", width: "300px", height: "200px" }), }); await flushUpdates(); const img = document.querySelector('shade-image img'); expect(img).not.toBeNull(); expect(img.style.width).toBe('300px'); expect(img.style.height).toBe('200px'); }); }); it('should apply objectFit prop', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Image, { src: "https://example.com/photo.jpg", objectFit: "contain" }), }); await flushUpdates(); const img = document.querySelector('shade-image img'); expect(img).not.toBeNull(); expect(img.style.objectFit).toBe('contain'); }); }); it('should set loading="lazy" when lazy prop is true', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Image, { src: "https://example.com/photo.jpg", lazy: true }), }); await flushUpdates(); const img = document.querySelector('shade-image img'); expect(img).not.toBeNull(); expect(img.loading).toBe('lazy'); }); }); it('should not set loading="lazy" by default', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Image, { src: "https://example.com/photo.jpg" }), }); await flushUpdates(); const img = document.querySelector('shade-image img'); expect(img).not.toBeNull(); expect(img.loading).not.toBe('lazy'); }); }); it('should show default fallback when image fails to load', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Image, { src: "invalid-url" }), }); await flushUpdates(); const imageComponent = document.querySelector('shade-image'); expect(imageComponent).not.toBeNull(); const img = imageComponent?.querySelector('img'); expect(img).not.toBeNull(); const errorEvent = new Event('error'); img.dispatchEvent(errorEvent); await flushUpdates(); expect(img.style.display).toBe('none'); const fallback = imageComponent?.querySelector('.image-fallback'); expect(fallback).not.toBeNull(); expect(fallback.style.display).toBe('flex'); expect(fallback.querySelector('shade-icon')).not.toBeNull(); }); }); it('should show custom fallback when image fails to load', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Image, { src: "invalid-url", fallback: createComponent("span", { "data-testid": "custom-fallback" }, "Image not found") }), }); await flushUpdates(); const imageComponent = document.querySelector('shade-image'); const img = imageComponent?.querySelector('img'); const errorEvent = new Event('error'); img.dispatchEvent(errorEvent); await flushUpdates(); const customFallback = imageComponent?.querySelector('[data-testid="custom-fallback"]'); expect(customFallback).not.toBeNull(); expect(customFallback?.textContent).toBe('Image not found'); }); }); it('should set data-preview attribute when preview is enabled', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Image, { src: "https://example.com/photo.jpg", preview: true }), }); await flushUpdates(); const imageComponent = document.querySelector('shade-image'); expect(imageComponent).not.toBeNull(); expect(imageComponent.hasAttribute('data-preview')).toBe(true); const previewIcon = imageComponent.querySelector('.image-preview-icon'); expect(previewIcon).not.toBeNull(); }); }); it('should not show preview icon when preview is disabled', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Image, { src: "https://example.com/photo.jpg" }), }); await flushUpdates(); const imageComponent = document.querySelector('shade-image'); expect(imageComponent.hasAttribute('data-preview')).toBe(false); const previewIcon = imageComponent.querySelector('.image-preview-icon'); expect(previewIcon).toBeNull(); }); }); it('should open lightbox when clicking a preview-enabled image', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Image, { src: "https://example.com/photo.jpg", alt: "My photo", preview: true }), }); await flushUpdates(); const img = document.querySelector('shade-image img'); img.click(); await flushUpdates(); const lightbox = document.querySelector('.lightbox-backdrop'); expect(lightbox).not.toBeNull(); const lightboxImg = lightbox?.querySelector('.lightbox-image'); expect(lightboxImg).not.toBeNull(); expect(lightboxImg.src).toBe('https://example.com/photo.jpg'); expect(lightboxImg.alt).toBe('My photo'); // Toolbar should be present const toolbar = lightbox?.querySelector('.lightbox-toolbar'); expect(toolbar).not.toBeNull(); // Clean up lightbox lightbox?.remove(); }); }); it('should not open lightbox when clicking a non-preview image', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Image, { src: "https://example.com/photo.jpg" }), }); await flushUpdates(); const img = document.querySelector('shade-image img'); img.click(); await flushUpdates(); const lightbox = document.querySelector('.lightbox-backdrop'); expect(lightbox).toBeNull(); }); }); it('should close lightbox when pressing Escape', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Image, { src: "https://example.com/photo.jpg", preview: true }), }); await flushUpdates(); const img = document.querySelector('shade-image img'); img.click(); await flushUpdates(); let lightbox = document.querySelector('.lightbox-backdrop'); expect(lightbox).not.toBeNull(); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await flushUpdates(); await flushUpdates(); lightbox = document.querySelector('.lightbox-backdrop'); expect(lightbox).toBeNull(); }); }); it('should have zoom controls in the lightbox', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Image, { src: "https://example.com/photo.jpg", preview: true }), }); await flushUpdates(); const img = document.querySelector('shade-image img'); img.click(); await flushUpdates(); const lightbox = document.querySelector('.lightbox-backdrop'); expect(lightbox).not.toBeNull(); const zoomIn = lightbox?.querySelector('.lightbox-zoom-in'); const zoomOut = lightbox?.querySelector('.lightbox-zoom-out'); const rotate = lightbox?.querySelector('.lightbox-rotate'); expect(zoomIn).not.toBeNull(); expect(zoomOut).not.toBeNull(); expect(rotate).not.toBeNull(); // Clean up lightbox?.remove(); }); }); it('should be focusable when preview is enabled', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Image, { src: "https://example.com/photo.jpg", preview: true }), }); await flushUpdates(); const imageComponent = document.querySelector('shade-image'); expect(imageComponent.getAttribute('tabindex')).toBe('0'); }); }); it('should not be focusable when preview is disabled', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Image, { src: "https://example.com/photo.jpg" }), }); await flushUpdates(); const imageComponent = document.querySelector('shade-image'); expect(imageComponent.hasAttribute('tabindex')).toBe(false); }); }); it('should open lightbox when pressing Enter on a preview-enabled image', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Image, { src: "https://example.com/photo.jpg", alt: "My photo", preview: true }), }); await flushUpdates(); const imageComponent = document.querySelector('shade-image'); imageComponent.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await flushUpdates(); const lightbox = document.querySelector('.lightbox-backdrop'); expect(lightbox).not.toBeNull(); lightbox?.remove(); }); }); it('should open lightbox when pressing Space on a preview-enabled image', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Image, { src: "https://example.com/photo.jpg", alt: "My photo", preview: true }), }); await flushUpdates(); const imageComponent = document.querySelector('shade-image'); imageComponent.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })); await flushUpdates(); const lightbox = document.querySelector('.lightbox-backdrop'); expect(lightbox).not.toBeNull(); lightbox?.remove(); }); }); it('should store src and alt as data attributes', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Image, { src: "https://example.com/photo.jpg", alt: "Test" }), }); await flushUpdates(); const imageComponent = document.querySelector('shade-image'); expect(imageComponent.getAttribute('data-src')).toBe('https://example.com/photo.jpg'); expect(imageComponent.getAttribute('data-alt')).toBe('Test'); }); }); }); describe('ImageGroup component', () => { beforeEach(() => { document.body.innerHTML = '<div id="root"></div>'; }); afterEach(() => { document.body.innerHTML = ''; }); it('should render children images', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(ImageGroup, null, createComponent(Image, { src: "https://example.com/1.jpg", alt: "Image 1", preview: true }), createComponent(Image, { src: "https://example.com/2.jpg", alt: "Image 2", preview: true }), createComponent(Image, { src: "https://example.com/3.jpg", alt: "Image 3", preview: true }))), }); await flushUpdates(); const group = document.querySelector('shade-image-group'); expect(group).not.toBeNull(); const images = group?.querySelectorAll('shade-image'); expect(images?.length).toBe(3); }); }); it('should apply gap prop', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(ImageGroup, { gap: "16px" }, createComponent(Image, { src: "https://example.com/1.jpg", preview: true }), createComponent(Image, { src: "https://example.com/2.jpg", preview: true }))), }); await flushUpdates(); const group = document.querySelector('shade-image-group'); expect(group).not.toBeNull(); expect(group.style.gap).toBe('16px'); }); }); it('should open lightbox with navigation when clicking an image in a group', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(ImageGroup, null, createComponent(Image, { src: "https://example.com/1.jpg", alt: "Image 1", preview: true }), createComponent(Image, { src: "https://example.com/2.jpg", alt: "Image 2", preview: true }), createComponent(Image, { src: "https://example.com/3.jpg", alt: "Image 3", preview: true }))), }); await flushUpdates(); const images = document.querySelectorAll('shade-image img'); expect(images.length).toBe(3); images[1].click(); await flushUpdates(); const lightbox = document.querySelector('.lightbox-backdrop'); expect(lightbox).not.toBeNull(); // Should have navigation arrows const prevBtn = lightbox?.querySelector('.lightbox-prev'); const nextBtn = lightbox?.querySelector('.lightbox-next'); expect(prevBtn).not.toBeNull(); expect(nextBtn).not.toBeNull(); // Should show the counter const counter = lightbox?.querySelector('.lightbox-counter'); expect(counter).not.toBeNull(); expect(counter?.textContent).toContain('2 / 3'); // Clean up lightbox?.remove(); }); }); it('should navigate to next image in group lightbox', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(ImageGroup, null, createComponent(Image, { src: "https://example.com/1.jpg", alt: "Image 1", preview: true }), createComponent(Image, { src: "https://example.com/2.jpg", alt: "Image 2", preview: true }), createComponent(Image, { src: "https://example.com/3.jpg", alt: "Image 3", preview: true }))), }); await flushUpdates(); const images = document.querySelectorAll('shade-image img'); images[0].click(); await flushUpdates(); const lightbox = document.querySelector('.lightbox-backdrop'); expect(lightbox).not.toBeNull(); const lightboxImg = lightbox?.querySelector('.lightbox-image'); expect(lightboxImg.src).toBe('https://example.com/1.jpg'); // Click next const nextBtn = lightbox?.querySelector('.lightbox-next'); nextBtn.click(); await flushUpdates(); expect(lightboxImg.src).toBe('https://example.com/2.jpg'); const counter = lightbox?.querySelector('.lightbox-counter'); expect(counter?.textContent).toContain('2 / 3'); // Clean up lightbox?.remove(); }); }); it('should navigate to previous image in group lightbox', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(ImageGroup, null, createComponent(Image, { src: "https://example.com/1.jpg", alt: "Image 1", preview: true }), createComponent(Image, { src: "https://example.com/2.jpg", alt: "Image 2", preview: true }), createComponent(Image, { src: "https://example.com/3.jpg", alt: "Image 3", preview: true }))), }); await flushUpdates(); const images = document.querySelectorAll('shade-image img'); images[0].click(); await flushUpdates(); const lightbox = document.querySelector('.lightbox-backdrop'); const lightboxImg = lightbox?.querySelector('.lightbox-image'); // Click prev should wrap to last image const prevBtn = lightbox?.querySelector('.lightbox-prev'); prevBtn.click(); await flushUpdates(); expect(lightboxImg.src).toBe('https://example.com/3.jpg'); const counter = lightbox?.querySelector('.lightbox-counter'); expect(counter?.textContent).toContain('3 / 3'); // Clean up lightbox?.remove(); }); }); it('should navigate with keyboard arrows in group lightbox', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent(ImageGroup, null, createComponent(Image, { src: "https://example.com/1.jpg", alt: "Image 1", preview: true }), createComponent(Image, { src: "https://example.com/2.jpg", alt: "Image 2", preview: true }))), }); await flushUpdates(); const images = document.querySelectorAll('shade-image img'); images[0].click(); await flushUpdates(); const lightbox = document.querySelector('.lightbox-backdrop'); expect(lightbox).not.toBeNull(); const lightboxImg = lightbox?.querySelector('.lightbox-image'); expect(lightboxImg.src).toBe('https://example.com/1.jpg'); // Navigate with ArrowRight document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })); await flushUpdates(); expect(lightboxImg.src).toBe('https://example.com/2.jpg'); // Navigate with ArrowLeft document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' })); await flushUpdates(); expect(lightboxImg.src).toBe('https://example.com/1.jpg'); // Clean up lightbox?.remove(); }); }); it('should zoom in and out in the lightbox', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Image, { src: "https://example.com/photo.jpg", preview: true }), }); await flushUpdates(); const img = document.querySelector('shade-image img'); img.click(); await flushUpdates(); const lightbox = document.querySelector('.lightbox-backdrop'); expect(lightbox).not.toBeNull(); const lightboxImg = lightbox?.querySelector('.lightbox-image'); // Zoom in const zoomInBtn = lightbox?.querySelector('.lightbox-zoom-in'); zoomInBtn.click(); await flushUpdates(); expect(lightboxImg.style.transform).toContain('scale(1.25)'); // Zoom out const zoomOutBtn = lightbox?.querySelector('.lightbox-zoom-out'); zoomOutBtn.click(); await flushUpdates(); expect(lightboxImg.style.transform).toContain('scale(1)'); // Clean up lightbox?.remove(); }); }); it('should rotate in the lightbox', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Image, { src: "https://example.com/photo.jpg", preview: true }), }); await flushUpdates(); const img = document.querySelector('shade-image img'); img.click(); await flushUpdates(); const lightbox = document.querySelector('.lightbox-backdrop'); const lightboxImg = lightbox?.querySelector('.lightbox-image'); const rotateBtn = lightbox?.querySelector('.lightbox-rotate'); rotateBtn.click(); await flushUpdates(); expect(lightboxImg.style.transform).toContain('rotate(90deg)'); // Clean up lightbox?.remove(); }); }); it('should close lightbox when clicking the close button', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Image, { src: "https://example.com/photo.jpg", preview: true }), }); await flushUpdates(); const img = document.querySelector('shade-image img'); img.click(); await flushUpdates(); let lightbox = document.querySelector('.lightbox-backdrop'); expect(lightbox).not.toBeNull(); const closeBtn = lightbox?.querySelector('.lightbox-close'); closeBtn.click(); await flushUpdates(); await flushUpdates(); lightbox = document.querySelector('.lightbox-backdrop'); expect(lightbox).toBeNull(); }); }); it('should close lightbox when clicking the backdrop', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Image, { src: "https://example.com/photo.jpg", preview: true }), }); await flushUpdates(); const img = document.querySelector('shade-image img'); img.click(); await flushUpdates(); const lightbox = document.querySelector('.lightbox-backdrop'); expect(lightbox).not.toBeNull(); // Click on the backdrop itself (not child elements) lightbox.dispatchEvent(new MouseEvent('click', { bubbles: true })); await flushUpdates(); await flushUpdates(); expect(document.querySelector('.lightbox-backdrop')).toBeNull(); }); }); it('should apply style overrides', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(Image, { src: "https://example.com/photo.jpg", style: { margin: '10px' } }), }); await flushUpdates(); const imageComponent = document.querySelector('shade-image'); expect(imageComponent.style.margin).toBe('10px'); }); }); }); //# sourceMappingURL=image.spec.js.map