@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
605 lines • 29.1 kB
JavaScript
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