@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
677 lines • 33.3 kB
JavaScript
import { createInjector } from '@furystack/inject';
import { createComponent, flushUpdates, initializeShadeRoot } from '@furystack/shades';
import { sleepAsync, usingAsync } from '@furystack/utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Carousel } from './carousel.js';
describe('Carousel', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="root"></div>';
});
afterEach(() => {
document.body.innerHTML = '';
});
const createSlides = () => [
createComponent("div", { id: "slide-1" }, "Slide 1"),
createComponent("div", { id: "slide-2" }, "Slide 2"),
createComponent("div", { id: "slide-3" }, "Slide 3"),
];
it('should render all slides', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides() }),
});
await flushUpdates();
expect(document.getElementById('slide-1')).toBeTruthy();
expect(document.getElementById('slide-2')).toBeTruthy();
expect(document.getElementById('slide-3')).toBeTruthy();
});
});
it('should render dot indicators by default', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides() }),
});
await flushUpdates();
const dots = document.querySelectorAll('.carousel-dot');
expect(dots.length).toBe(3);
});
});
it('should hide dots when dots prop is false', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides(), dots: false }),
});
await flushUpdates();
const dots = document.querySelectorAll('.carousel-dot');
expect(dots.length).toBe(0);
});
});
it('should render prev and next arrow buttons', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides() }),
});
await flushUpdates();
const prevButton = document.querySelector('.carousel-arrow-prev');
const nextButton = document.querySelector('.carousel-arrow-next');
expect(prevButton).toBeTruthy();
expect(nextButton).toBeTruthy();
});
});
it('should not render arrows or dots for a single slide', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: [createComponent("div", null, "Only slide")] }),
});
await flushUpdates();
const arrows = document.querySelectorAll('.carousel-arrow');
const dots = document.querySelectorAll('.carousel-dot');
expect(arrows.length).toBe(0);
expect(dots.length).toBe(0);
});
});
it('should set the first dot as active by default', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides() }),
});
await flushUpdates();
const activeDots = document.querySelectorAll('.carousel-dot[data-active]');
expect(activeDots.length).toBe(1);
const dots = document.querySelectorAll('.carousel-dot');
expect(dots[0].hasAttribute('data-active')).toBe(true);
});
});
it('should respect defaultActiveIndex', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides(), defaultActiveIndex: 2 }),
});
await flushUpdates();
const dots = document.querySelectorAll('.carousel-dot');
expect(dots[2].hasAttribute('data-active')).toBe(true);
expect(dots[0].hasAttribute('data-active')).toBe(false);
});
});
it('should set the vertical data attribute', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides(), vertical: true }),
});
await flushUpdates();
const carousel = document.querySelector('shade-carousel');
expect(carousel.hasAttribute('data-vertical')).toBe(true);
});
});
it('should use slide effect by default', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides() }),
});
await flushUpdates();
const track = document.querySelector('.carousel-track');
expect(track).toBeTruthy();
expect(document.querySelector('.carousel-fade-container')).toBeFalsy();
});
});
it('should use fade effect when specified', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides(), effect: "fade" }),
});
await flushUpdates();
const fadeContainer = document.querySelector('.carousel-fade-container');
expect(fadeContainer).toBeTruthy();
expect(document.querySelector('.carousel-track')).toBeFalsy();
});
});
it('should mark the first fade slide as active by default', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides(), effect: "fade" }),
});
await flushUpdates();
const fadeSlides = document.querySelectorAll('.carousel-fade-slide');
expect(fadeSlides[0].hasAttribute('data-active')).toBe(true);
expect(fadeSlides[1].hasAttribute('data-active')).toBe(false);
});
});
it('should have proper ARIA attributes on host element', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides() }),
});
await flushUpdates();
const carousel = document.querySelector('shade-carousel');
expect(carousel.getAttribute('role')).toBe('region');
expect(carousel.getAttribute('aria-roledescription')).toBe('carousel');
expect(carousel.getAttribute('tabindex')).toBe('0');
});
});
it('should render slide groups with role attribute', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides() }),
});
await flushUpdates();
const carousel = document.querySelector('shade-carousel');
const slideGroups = carousel.querySelectorAll('[role="group"]');
expect(slideGroups.length).toBe(3);
});
});
it('should call onChange when navigating via dots', async () => {
const handleChange = vi.fn();
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides(), onChange: handleChange }),
});
await flushUpdates();
const dots = document.querySelectorAll('.carousel-dot');
dots[2].click();
expect(handleChange).toHaveBeenCalledWith(2);
});
});
it('should call onChange when navigating via arrows', async () => {
const handleChange = vi.fn();
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides(), onChange: handleChange }),
});
await flushUpdates();
const nextButton = document.querySelector('.carousel-arrow-next');
nextButton.click();
expect(handleChange).toHaveBeenCalledWith(1);
});
});
it('should wrap around when navigating past the last slide', async () => {
const handleChange = vi.fn();
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides(), defaultActiveIndex: 2, onChange: handleChange }),
});
await flushUpdates();
const nextButton = document.querySelector('.carousel-arrow-next');
nextButton.click();
expect(handleChange).toHaveBeenCalledWith(0);
});
});
it('should wrap around when navigating before the first slide', async () => {
const handleChange = vi.fn();
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides(), onChange: handleChange }),
});
await flushUpdates();
const prevButton = document.querySelector('.carousel-arrow-prev');
prevButton.click();
expect(handleChange).toHaveBeenCalledWith(2);
});
});
it('should render horizontal arrows with correct symbols', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides() }),
});
await flushUpdates();
const prevButton = document.querySelector('.carousel-arrow-prev');
const nextButton = document.querySelector('.carousel-arrow-next');
expect(prevButton.querySelector('shade-icon')).not.toBeNull();
expect(nextButton.querySelector('shade-icon')).not.toBeNull();
});
});
it('should render vertical arrows with correct symbols', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides(), vertical: true }),
});
await flushUpdates();
const prevButton = document.querySelector('.carousel-arrow-prev');
const nextButton = document.querySelector('.carousel-arrow-next');
expect(prevButton.querySelector('shade-icon')).not.toBeNull();
expect(nextButton.querySelector('shade-icon')).not.toBeNull();
});
});
it('should clamp defaultActiveIndex to valid range', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides(), defaultActiveIndex: 99 }),
});
await flushUpdates();
const dots = document.querySelectorAll('.carousel-dot');
// Should clamp to last slide (index 2)
expect(dots[2].hasAttribute('data-active')).toBe(true);
});
});
it('should apply custom style', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides(), style: { height: '300px' } }),
});
await flushUpdates();
const carousel = document.querySelector('shade-carousel');
expect(carousel.style.height).toBe('300px');
});
});
it('should handle empty slides array gracefully', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: [] }),
});
await flushUpdates();
const carousel = document.querySelector('shade-carousel');
expect(carousel).toBeTruthy();
expect(document.querySelectorAll('.carousel-dot').length).toBe(0);
expect(document.querySelectorAll('.carousel-arrow').length).toBe(0);
});
});
describe('keyboard navigation', () => {
it('should navigate to next slide on ArrowRight', async () => {
const handleChange = vi.fn();
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides(), onChange: handleChange }),
});
await flushUpdates();
const carousel = document.querySelector('shade-carousel');
carousel.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
expect(handleChange).toHaveBeenCalledWith(1);
});
});
it('should navigate to previous slide on ArrowLeft', async () => {
const handleChange = vi.fn();
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides(), defaultActiveIndex: 2, onChange: handleChange }),
});
await flushUpdates();
const carousel = document.querySelector('shade-carousel');
carousel.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
expect(handleChange).toHaveBeenCalledWith(1);
});
});
it('should navigate with ArrowDown in vertical mode', async () => {
const handleChange = vi.fn();
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides(), vertical: true, onChange: handleChange }),
});
await flushUpdates();
const carousel = document.querySelector('shade-carousel');
carousel.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
expect(handleChange).toHaveBeenCalledWith(1);
});
});
it('should navigate with ArrowUp in vertical mode', async () => {
const handleChange = vi.fn();
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides(), vertical: true, defaultActiveIndex: 2, onChange: handleChange }),
});
await flushUpdates();
const carousel = document.querySelector('shade-carousel');
carousel.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
expect(handleChange).toHaveBeenCalledWith(1);
});
});
it('should not react to ArrowDown/ArrowUp in horizontal mode', async () => {
const handleChange = vi.fn();
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides(), onChange: handleChange }),
});
await flushUpdates();
const carousel = document.querySelector('shade-carousel');
carousel.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
carousel.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
expect(handleChange).not.toHaveBeenCalled();
});
});
});
describe('touch / swipe support', () => {
const createTouchEvent = (type, x, y) => {
const touchObj = { clientX: x, clientY: y, identifier: 0, target: document.body };
if (type === 'touchstart') {
return new TouchEvent(type, { touches: [touchObj] });
}
return new TouchEvent(type, { changedTouches: [touchObj] });
};
it('should go to next slide on horizontal left swipe', async () => {
const handleChange = vi.fn();
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides(), onChange: handleChange }),
});
await flushUpdates();
const carousel = document.querySelector('shade-carousel');
carousel.dispatchEvent(createTouchEvent('touchstart', 200, 100));
carousel.dispatchEvent(createTouchEvent('touchend', 100, 100));
expect(handleChange).toHaveBeenCalledWith(1);
});
});
it('should go to previous slide on horizontal right swipe', async () => {
const handleChange = vi.fn();
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides(), defaultActiveIndex: 2, onChange: handleChange }),
});
await flushUpdates();
const carousel = document.querySelector('shade-carousel');
carousel.dispatchEvent(createTouchEvent('touchstart', 100, 100));
carousel.dispatchEvent(createTouchEvent('touchend', 200, 100));
expect(handleChange).toHaveBeenCalledWith(1);
});
});
it('should not navigate on small swipe distance', async () => {
const handleChange = vi.fn();
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides(), onChange: handleChange }),
});
await flushUpdates();
const carousel = document.querySelector('shade-carousel');
carousel.dispatchEvent(createTouchEvent('touchstart', 100, 100));
carousel.dispatchEvent(createTouchEvent('touchend', 120, 100));
expect(handleChange).not.toHaveBeenCalled();
});
});
it('should handle vertical swipe in vertical mode', async () => {
const handleChange = vi.fn();
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides(), vertical: true, onChange: handleChange }),
});
await flushUpdates();
const carousel = document.querySelector('shade-carousel');
carousel.dispatchEvent(createTouchEvent('touchstart', 100, 200));
carousel.dispatchEvent(createTouchEvent('touchend', 100, 100));
expect(handleChange).toHaveBeenCalledWith(1);
});
});
});
describe('autoplay', () => {
it('should auto-advance slides when autoplay is enabled', async () => {
const handleChange = vi.fn();
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides(), autoplay: true, autoplayInterval: 100, onChange: handleChange }),
});
await flushUpdates();
// Wait for at least one autoplay cycle
await sleepAsync(200);
expect(handleChange).toHaveBeenCalled();
});
});
it('should not autoplay for a single slide', async () => {
const handleChange = vi.fn();
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: [createComponent("div", null, "Only")], autoplay: true, autoplayInterval: 100, onChange: handleChange }),
});
await sleepAsync(300);
expect(handleChange).not.toHaveBeenCalled();
});
});
});
describe('fade effect DOM updates', () => {
it('should update active fade slide when navigating', async () => {
const handleChange = vi.fn();
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides(), effect: "fade", onChange: handleChange }),
});
await flushUpdates();
const nextButton = document.querySelector('.carousel-arrow-next');
nextButton.click();
await flushUpdates();
const fadeSlides = document.querySelectorAll('.carousel-fade-slide');
expect(fadeSlides[0].hasAttribute('data-active')).toBe(false);
expect(fadeSlides[1].hasAttribute('data-active')).toBe(true);
});
});
it('should update dots when navigating in fade mode', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides(), effect: "fade" }),
});
await flushUpdates();
const nextButton = document.querySelector('.carousel-arrow-next');
nextButton.click();
await flushUpdates();
const dots = document.querySelectorAll('.carousel-dot');
expect(dots[0].hasAttribute('data-active')).toBe(false);
expect(dots[1].hasAttribute('data-active')).toBe(true);
});
});
});
describe('slide effect DOM updates', () => {
it('should update track transform when navigating', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides() }),
});
await flushUpdates();
const nextButton = document.querySelector('.carousel-arrow-next');
nextButton.click();
await flushUpdates();
const track = document.querySelector('.carousel-track');
expect(track.style.transform).toContain('-100%');
});
});
it('should update dots when navigating in slide mode', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides() }),
});
await flushUpdates();
const nextButton = document.querySelector('.carousel-arrow-next');
nextButton.click();
await flushUpdates();
const dots = document.querySelectorAll('.carousel-dot');
expect(dots[0].hasAttribute('data-active')).toBe(false);
expect(dots[1].hasAttribute('data-active')).toBe(true);
});
});
});
it('should not remove data-vertical when set to false', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides(), vertical: false }),
});
await flushUpdates();
const carousel = document.querySelector('shade-carousel');
expect(carousel.hasAttribute('data-vertical')).toBe(false);
});
});
describe('vertical slide mode DOM updates', () => {
it('should update track transform with translateY when navigating vertically', async () => {
const handleChange = vi.fn();
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides(), vertical: true, style: { height: '300px' }, onChange: handleChange }),
});
await flushUpdates();
const nextButton = document.querySelector('.carousel-arrow-next');
nextButton.click();
await flushUpdates();
const track = document.querySelector('.carousel-track');
expect(track.style.transform).toContain('translateY');
});
});
});
describe('vertical swipe support', () => {
const createTouchEvent = (type, x, y) => {
const touchObj = { clientX: x, clientY: y, identifier: 0, target: document.body };
if (type === 'touchstart') {
return new TouchEvent(type, { touches: [touchObj] });
}
return new TouchEvent(type, { changedTouches: [touchObj] });
};
it('should go to previous slide on vertical swipe down', async () => {
const handleChange = vi.fn();
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides(), vertical: true, defaultActiveIndex: 2, onChange: handleChange }),
});
await flushUpdates();
const carousel = document.querySelector('shade-carousel');
carousel.dispatchEvent(createTouchEvent('touchstart', 100, 100));
carousel.dispatchEvent(createTouchEvent('touchend', 100, 200));
expect(handleChange).toHaveBeenCalledWith(1);
});
});
it('should not navigate on small vertical swipe', async () => {
const handleChange = vi.fn();
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides(), vertical: true, onChange: handleChange }),
});
await flushUpdates();
const carousel = document.querySelector('shade-carousel');
carousel.dispatchEvent(createTouchEvent('touchstart', 100, 100));
carousel.dispatchEvent(createTouchEvent('touchend', 100, 130));
expect(handleChange).not.toHaveBeenCalled();
});
});
});
describe('fade effect with defaultActiveIndex', () => {
it('should set correct initial active slide for fade effect with non-zero index', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: createComponent(Carousel, { slides: createSlides(), effect: "fade", defaultActiveIndex: 1 }),
});
await flushUpdates();
const fadeSlides = document.querySelectorAll('.carousel-fade-slide');
expect(fadeSlides[0].hasAttribute('data-active')).toBe(false);
expect(fadeSlides[1].hasAttribute('data-active')).toBe(true);
expect(fadeSlides[2].hasAttribute('data-active')).toBe(false);
});
});
});
});
//# sourceMappingURL=carousel.spec.js.map